diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index 2b014354..e7c0481d 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -14,6 +14,7 @@ from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError from redisvl.mcp.runtime import BindingRuntime from redisvl.mcp.settings import MCPSettings +from redisvl.mcp.tools.list_indexes import register_list_indexes_tool from redisvl.mcp.tools.search import register_search_tool from redisvl.mcp.tools.upsert import register_upsert_tool from redisvl.redis.connection import RedisConnectionFactory, is_version_gte @@ -248,6 +249,8 @@ def _register_tools(self) -> None: if len(self._bindings) == 1: search_schema = next(iter(self._bindings.values())).schema + # Discovery is always available so clients can enumerate indexes. + register_list_indexes_tool(self) register_search_tool(self, search_schema) if not self.mcp_settings.read_only: register_upsert_tool(self) diff --git a/redisvl/mcp/tools/list_indexes.py b/redisvl/mcp/tools/list_indexes.py new file mode 100644 index 00000000..919c14da --- /dev/null +++ b/redisvl/mcp/tools/list_indexes.py @@ -0,0 +1,92 @@ +from typing import TYPE_CHECKING, Any + +from redisvl.mcp.auth import ensure_tool_scope +from redisvl.mcp.runtime import BindingRuntime + +if TYPE_CHECKING: + from redisvl.mcp.server import RedisVLMCPServer + +DEFAULT_LIST_INDEXES_DESCRIPTION = ( + "List the logical indexes configured on this server. Each entry reports the " + "index id, an optional description, whether upsert is available, the " + "filterable fields discovered from the index, and any explicitly configured " + "limits. Call this first on a multi-index server to choose the correct " + "index for search-records or upsert-records." +) + +# Runtime limits surfaced to clients, included only when explicitly configured. +_LIMIT_FIELDS = ("max_limit", "max_upsert_records") + + +def _binding_fields(binding_runtime: BindingRuntime) -> list[dict[str, str]]: + """Return a binding's shared filterable fields from its inspected schema. + + The vector field and the configured default embed-source text field are + omitted: they are implementation inputs, not fields a client filters on. + """ + embed_source = binding_runtime.binding.runtime.default_embed_text_field + fields: list[dict[str, str]] = [] + for field in binding_runtime.schema.fields.values(): + field_type = str(getattr(field.type, "value", field.type)) + if field_type.lower() == "vector": + continue + if field.name == embed_source: + continue + fields.append({"name": field.name, "type": field_type}) + return fields + + +def _binding_limits(binding_runtime: BindingRuntime) -> dict[str, int]: + """Return runtime limits that were explicitly configured for the binding. + + Defaults are intentionally excluded so the output reflects deliberate + overrides rather than implementation defaults. + """ + runtime = binding_runtime.binding.runtime + configured = runtime.model_fields_set + return { + name: getattr(runtime, name) for name in _LIMIT_FIELDS if name in configured + } + + +def _describe_binding(binding_runtime: BindingRuntime) -> dict[str, Any]: + """Build the deterministic discovery payload for a single binding.""" + entry: dict[str, Any] = {"id": binding_runtime.binding_id} + if binding_runtime.binding.description is not None: + entry["description"] = binding_runtime.binding.description + # Reflects both global read-only and the per-index read_only policy. + entry["upsert_available"] = not binding_runtime.effective_read_only + entry["fields"] = _binding_fields(binding_runtime) + limits = _binding_limits(binding_runtime) + if limits: + entry["limits"] = limits + return entry + + +def list_indexes(server: "RedisVLMCPServer") -> dict[str, Any]: + """Return the discovery payload for every configured binding. + + The Redis index name (``redis_name``) is intentionally never exposed. + """ + return { + "indexes": [ + _describe_binding(binding_runtime) + for binding_runtime in server._bindings.values() + ], + } + + +def register_list_indexes_tool(server: "RedisVLMCPServer") -> None: + """Register the always-available, read-only `list-indexes` MCP tool.""" + description = ( + getattr(server.mcp_settings, "tool_list_indexes_description", None) + or DEFAULT_LIST_INDEXES_DESCRIPTION + ) + + async def list_indexes_tool(): + """FastMCP wrapper for the `list-indexes` tool.""" + read_scope = getattr(getattr(server, "auth_config", None), "read_scope", None) + ensure_tool_scope(server, read_scope) + return list_indexes(server) + + server.tool(name="list-indexes", description=description)(list_indexes_tool) diff --git a/tests/integration/test_mcp/test_server_startup.py b/tests/integration/test_mcp/test_server_startup.py index 170aa32c..be3be423 100644 --- a/tests/integration/test_mcp/test_server_startup.py +++ b/tests/integration/test_mcp/test_server_startup.py @@ -9,6 +9,7 @@ from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError from redisvl.mcp.server import RedisVLMCPServer from redisvl.mcp.settings import MCPSettings +from redisvl.mcp.tools.list_indexes import list_indexes from redisvl.redis.connection import is_version_gte from redisvl.schema import IndexSchema from tests.conftest import get_redis_version_async @@ -723,3 +724,78 @@ async def test_server_startup_fails_when_one_binding_is_invalid( assert server._lifecycle_state.name == "STOPPED" assert server._bindings == {} + + +@pytest.mark.asyncio +async def test_list_indexes_derives_fields_from_inspected_schema( + monkeypatch, existing_index, multi_index_config_path +): + knowledge = await existing_index(index_name="mcp-list-knowledge") + tickets = await existing_index(index_name="mcp-list-tickets") + monkeypatch.setattr( + "redisvl.mcp.server.resolve_vectorizer_class", + lambda class_name: FakeVectorizer, + ) + server = RedisVLMCPServer( + MCPSettings( + config=multi_index_config_path( + { + # Vector binding: content is the embed source. + "knowledge": { + "redis_name": knowledge.name, + "description": "Product docs", + "vectorizer": { + "class": "FakeVectorizer", + "model": "fake-model", + "dims": 3, + }, + "search": {"type": "vector"}, + "runtime": { + "text_field_name": "content", + "vector_field_name": "embedding", + "default_embed_text_field": "content", + "max_limit": 25, + }, + }, + # Fulltext binding: no embed source, read-only. + "tickets": { + "redis_name": tickets.name, + "read_only": True, + "search": {"type": "fulltext"}, + "runtime": {"text_field_name": "content"}, + }, + } + ) + ) + ) + + await server.startup() + + try: + result = list_indexes(server) + indexes = {entry["id"]: entry for entry in result["indexes"]} + + # Both bindings are discoverable; redis_name is never leaked. + assert set(indexes) == {"knowledge", "tickets"} + for entry in indexes.values(): + assert "redis_name" not in entry + assert knowledge.name not in entry.values() + assert tickets.name not in entry.values() + + # Fields come from the inspected schema. The vector field is always + # omitted; the embed-source field is omitted only where configured. + knowledge_fields = {f["name"] for f in indexes["knowledge"]["fields"]} + tickets_fields = {f["name"] for f in indexes["tickets"]["fields"]} + assert "embedding" not in knowledge_fields + assert "embedding" not in tickets_fields + assert "content" not in knowledge_fields # embed source omitted + assert "content" in tickets_fields # no embed source configured + + # Per-index write policy and explicit limits are reflected. + assert indexes["knowledge"]["upsert_available"] is True + assert indexes["tickets"]["upsert_available"] is False + assert indexes["knowledge"]["limits"] == {"max_limit": 25} + assert "limits" not in indexes["tickets"] + assert indexes["knowledge"]["description"] == "Product docs" + finally: + await server.shutdown() diff --git a/tests/unit/test_mcp/test_list_indexes_tool_unit.py b/tests/unit/test_mcp/test_list_indexes_tool_unit.py new file mode 100644 index 00000000..10fb933e --- /dev/null +++ b/tests/unit/test_mcp/test_list_indexes_tool_unit.py @@ -0,0 +1,222 @@ +from types import SimpleNamespace +from typing import Any + +import pytest + +from redisvl.mcp.config import MCPConfig +from redisvl.mcp.runtime import BindingRuntime +from redisvl.mcp.tools.list_indexes import list_indexes, register_list_indexes_tool +from redisvl.schema import IndexSchema + + +def _schema() -> IndexSchema: + return IndexSchema.from_dict( + { + "index": { + "name": "docs-index", + "prefix": "doc", + "storage_type": "hash", + }, + "fields": [ + {"name": "title", "type": "text"}, + {"name": "content", "type": "text"}, + {"name": "category", "type": "tag"}, + {"name": "rating", "type": "numeric"}, + { + "name": "embedding", + "type": "vector", + "attrs": { + "algorithm": "flat", + "dims": 3, + "distance_metric": "cosine", + "datatype": "float32", + }, + }, + ], + } + ) + + +def _binding_runtime( + binding_id: str = "knowledge", + *, + runtime: dict[str, Any] | None = None, + description: str | None = None, + read_only: bool = False, + effective_read_only: bool = False, + schema: IndexSchema | None = None, +) -> BindingRuntime: + runtime_config = { + "vector_field_name": "embedding", + "default_embed_text_field": "content", + } + if runtime: + runtime_config.update(runtime) + + binding_dict: dict[str, Any] = { + "redis_name": f"{binding_id}-redis-name", + "read_only": read_only, + "vectorizer": {"class": "FakeVectorizer", "model": "test-model"}, + "search": {"type": "vector"}, + "runtime": runtime_config, + } + if description is not None: + binding_dict["description"] = description + + config = MCPConfig.model_validate( + { + "server": {"redis_url": "redis://localhost:6379"}, + "indexes": {binding_id: binding_dict}, + } + ) + return BindingRuntime( + binding_id=binding_id, + binding=config.indexes[binding_id], + index=SimpleNamespace(), + schema=schema or _schema(), + vectorizer=None, + supports_native_hybrid_search=False, + effective_read_only=effective_read_only, + ) + + +class FakeServer: + def __init__(self, bindings: list[BindingRuntime]): + self._bindings = {rt.binding_id: rt for rt in bindings} + self.mcp_settings = SimpleNamespace() + self.auth_config = None + self._auth_enabled = False + self.registered_tools: list[dict[str, Any]] = [] + + def tool(self, name=None, description=None, **kwargs): + def decorator(fn): + self.registered_tools.append( + {"name": name, "description": description, "fn": fn} + ) + return fn + + return decorator + + +def test_list_indexes_minimal_single_binding(): + server = FakeServer([_binding_runtime()]) + + result = list_indexes(server) + + assert result == { + "indexes": [ + { + "id": "knowledge", + "upsert_available": True, + "fields": [ + {"name": "title", "type": "text"}, + {"name": "category", "type": "tag"}, + {"name": "rating", "type": "numeric"}, + ], + } + ] + } + + +def test_list_indexes_omits_vector_and_embed_source_fields(): + server = FakeServer([_binding_runtime()]) + + fields = list_indexes(server)["indexes"][0]["fields"] + field_names = [field["name"] for field in fields] + + # embedding is the vector field; content is the default embed-source field. + assert "embedding" not in field_names + assert "content" not in field_names + + +def test_list_indexes_includes_description_when_configured(): + server = FakeServer([_binding_runtime(description="Product docs and runbooks")]) + + entry = list_indexes(server)["indexes"][0] + + assert entry["description"] == "Product docs and runbooks" + + +def test_list_indexes_omits_description_when_absent(): + server = FakeServer([_binding_runtime()]) + + assert "description" not in list_indexes(server)["indexes"][0] + + +def test_list_indexes_upsert_available_reflects_effective_read_only(): + server = FakeServer( + [ + _binding_runtime("knowledge", effective_read_only=False), + _binding_runtime("tickets", read_only=True, effective_read_only=True), + ] + ) + + indexes = {entry["id"]: entry for entry in list_indexes(server)["indexes"]} + + assert indexes["knowledge"]["upsert_available"] is True + assert indexes["tickets"]["upsert_available"] is False + + +def test_list_indexes_includes_limits_only_when_explicitly_configured(): + server = FakeServer( + [ + _binding_runtime( + "explicit", + runtime={"max_limit": 25, "max_upsert_records": 64}, + ), + _binding_runtime("defaults"), + ] + ) + + indexes = {entry["id"]: entry for entry in list_indexes(server)["indexes"]} + + assert indexes["explicit"]["limits"] == { + "max_limit": 25, + "max_upsert_records": 64, + } + assert "limits" not in indexes["defaults"] + + +def test_list_indexes_includes_only_the_explicitly_set_limit(): + server = FakeServer([_binding_runtime(runtime={"max_limit": 25})]) + + entry = list_indexes(server)["indexes"][0] + + assert entry["limits"] == {"max_limit": 25} + + +def test_list_indexes_never_exposes_redis_name(): + server = FakeServer([_binding_runtime()]) + + entry = list_indexes(server)["indexes"][0] + + assert "redis_name" not in entry + assert "knowledge-redis-name" not in entry.values() + + +def test_list_indexes_preserves_binding_order(): + server = FakeServer( + [ + _binding_runtime("knowledge"), + _binding_runtime("tickets"), + ] + ) + + ids = [entry["id"] for entry in list_indexes(server)["indexes"]] + + assert ids == ["knowledge", "tickets"] + + +@pytest.mark.asyncio +async def test_register_list_indexes_tool_is_read_only_and_callable(): + server = FakeServer([_binding_runtime()]) + + register_list_indexes_tool(server) + + assert len(server.registered_tools) == 1 + tool = server.registered_tools[0] + assert tool["name"] == "list-indexes" + assert tool["description"] + + result = await tool["fn"]() + assert result == list_indexes(server) diff --git a/tests/unit/test_mcp/test_server.py b/tests/unit/test_mcp/test_server.py index 22d5239e..a8c71358 100644 --- a/tests/unit/test_mcp/test_server.py +++ b/tests/unit/test_mcp/test_server.py @@ -384,6 +384,9 @@ async def fake_disconnect(self): "redisvl.mcp.server.register_search_tool", fake_register_search_tool ) monkeypatch.setattr("redisvl.mcp.server.register_upsert_tool", lambda server: None) + monkeypatch.setattr( + "redisvl.mcp.server.register_list_indexes_tool", lambda server: None + ) monkeypatch.setattr( "redisvl.mcp.server.AsyncSearchIndex.disconnect", fake_disconnect,