diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index b8955c483..d3d03491f 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -15,6 +15,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 @@ -246,8 +247,14 @@ 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: + # Expose upsert only when at least one binding is writable. A binding is + # read-only under global read-only mode or its own read_only policy, both + # of which are folded into effective_read_only; the per-call write check + # in the tool then rejects writes to any individual read-only binding. + if any(not rt.effective_read_only for rt in self._bindings.values()): register_upsert_tool(self) self._tools_registered = True diff --git a/redisvl/mcp/tools/list_indexes.py b/redisvl/mcp/tools/list_indexes.py new file mode 100644 index 000000000..58a6adefc --- /dev/null +++ b/redisvl/mcp/tools/list_indexes.py @@ -0,0 +1,96 @@ +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. + """ + # Mirror resolve_binding: with no bindings the server is not started (or has + # been torn down), so fail loudly rather than return an empty list that a + # client could misread as "no indexes configured". + if not server._bindings: + raise RuntimeError("MCP server has not been started") + 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.""" + + async def list_indexes_tool(): + """FastMCP wrapper for the `list-indexes` tool.""" + auth_config = getattr(server, "auth_config", None) + read_scope = auth_config.read_scope if auth_config is not None else None + ensure_tool_scope(server, read_scope) + return list_indexes(server) + + server.tool(name="list-indexes", description=DEFAULT_LIST_INDEXES_DESCRIPTION)( + list_indexes_tool + ) diff --git a/redisvl/mcp/tools/search.py b/redisvl/mcp/tools/search.py index 36d136f77..adbeab752 100644 --- a/redisvl/mcp/tools/search.py +++ b/redisvl/mcp/tools/search.py @@ -56,12 +56,15 @@ def _build_search_tool_description( """Build the `search-records` description from static text plus schema hints. With multiple bindings configured the schema is ambiguous (the caller picks - an index per call via `list-indexes`), so `schema` is None and only the - base description is returned. + an index per call via `list-indexes`), so per-field hints are omitted and a + routing note is appended instead. """ description = (base_description or DEFAULT_SEARCH_DESCRIPTION).strip() if schema is None: - return description + return ( + description + " Multiple indexes are configured: call list-indexes " + "first, then pass the chosen index id as the `index` argument." + ) # `exists` is currently accepted for any schema field in the MCP object filter. exists_fields = [field.name for field in schema.fields.values()] @@ -427,14 +430,21 @@ async def search_records( server: Any, *, query: str, + index: str | None = None, limit: int | None = None, offset: int = 0, filter: str | dict[str, Any] | None = None, return_fields: list[str] | None = None, ) -> dict[str, Any]: - """Execute `search-records` against the selected Redis index binding.""" + """Execute `search-records` against the selected Redis index binding. + + ``index`` names the logical binding to query. It is optional when exactly + one binding is configured (preserving single-index behavior) and required + when multiple bindings exist. The resolved logical id is echoed back in the + response so multi-index clients can confirm routing. + """ try: - rt = server.resolve_binding(None) + rt = server.resolve_binding(index) effective_limit, effective_return_fields = _validate_request( query=query, limit=limit, @@ -458,6 +468,7 @@ async def search_records( ) sliced_results = raw_results[offset : offset + effective_limit] return { + "index": rt.binding_id, "search_type": search_type, "offset": offset, "limit": effective_limit, @@ -485,6 +496,7 @@ def register_search_tool(server: Any, schema: IndexSchema | None) -> None: async def search_records_tool( query: str, + index: str | None = None, limit: int | None = None, offset: int = 0, filter: str | dict[str, Any] | None = None, @@ -497,6 +509,7 @@ async def search_records_tool( return await search_records( server, query=query, + index=index, limit=limit, offset=offset, filter=filter, diff --git a/redisvl/mcp/tools/upsert.py b/redisvl/mcp/tools/upsert.py index c0d3b7cc5..4137c9a1a 100644 --- a/redisvl/mcp/tools/upsert.py +++ b/redisvl/mcp/tools/upsert.py @@ -249,19 +249,27 @@ async def upsert_records( server: Any, *, records: list[dict[str, Any]], + index: str | None = None, id_field: str | None = None, skip_embedding_if_present: bool | None = None, ) -> dict[str, Any]: - """Execute `upsert-records` against the selected Redis index binding.""" + """Execute `upsert-records` against the selected Redis index binding. + + ``index`` names the logical binding to write to. It is optional when exactly + one binding is configured and required when multiple exist. Writes to a + read-only binding (whether from global read-only mode or the binding's own + ``read_only`` policy) are rejected with ``invalid_request``. The resolved + logical id is echoed back in the response. + """ try: - rt = server.resolve_binding(None) + rt = server.resolve_binding(index) if rt.effective_read_only: raise RedisVLMCPError( - "upsert-records is not permitted: binding is read-only", + f"index '{rt.binding_id}' is read-only", code=MCPErrorCode.FORBIDDEN, retryable=False, ) - index = rt.index + index_obj = rt.index runtime = rt.binding.runtime effective_skip_embedding = _validate_request( runtime=runtime, @@ -275,7 +283,7 @@ async def upsert_records( for record in prepared_records: _validate_record( record, - index=index, + index=index_obj, vector_field_name=runtime.vector_field_name, ) if rt.binding.supports_server_side_embedding: @@ -332,7 +340,7 @@ async def upsert_records( try: keys = await server.run_guarded( "upsert-records", - index.load(loadable_records, id_field=id_field), + index_obj.load(loadable_records, id_field=id_field), timeout_seconds=runtime.request_timeout_seconds, ) except Exception as exc: @@ -341,6 +349,7 @@ async def upsert_records( raise mapped from exc return { + "index": rt.binding_id, "status": "success", "keys_upserted": len(keys), "keys": keys, @@ -359,6 +368,7 @@ def register_upsert_tool(server: Any) -> None: async def upsert_records_tool( records: list[dict[str, Any]], + index: str | None = None, id_field: str | None = None, skip_embedding_if_present: bool | None = None, ): @@ -369,6 +379,7 @@ async def upsert_records_tool( return await upsert_records( server, records=records, + index=index, id_field=id_field, skip_embedding_if_present=skip_embedding_if_present, ) diff --git a/tests/integration/test_mcp/test_search_tool.py b/tests/integration/test_mcp/test_search_tool.py index a59f11c9b..03824fe64 100644 --- a/tests/integration/test_mcp/test_search_tool.py +++ b/tests/integration/test_mcp/test_search_tool.py @@ -214,6 +214,108 @@ async def started(search: dict, **kwargs) -> RedisVLMCPServer: await server.shutdown() +@pytest.fixture +async def multi_index_server( + monkeypatch, searchable_index, fulltext_only_index, tmp_path, redis_url +): + monkeypatch.setattr( + "redisvl.mcp.server.resolve_vectorizer_class", + lambda class_name: FakeVectorizer, + ) + + config = { + "server": {"redis_url": redis_url}, + "indexes": { + "knowledge": { + "redis_name": searchable_index.schema.index.name, + "search": {"type": "vector"}, + "vectorizer": { + "class": "FakeVectorizer", + "model": "fake-model", + "dims": 3, + }, + "runtime": { + "text_field_name": "content", + "vector_field_name": "embedding", + "default_embed_text_field": "content", + "default_limit": 2, + "max_limit": 5, + }, + }, + "tickets": { + "redis_name": fulltext_only_index.schema.index.name, + "search": {"type": "fulltext", "params": {"stopwords": None}}, + "runtime": { + "text_field_name": "content", + "vector_field_name": None, + "default_embed_text_field": None, + "default_limit": 2, + "max_limit": 5, + }, + }, + }, + } + config_path = tmp_path / "multi-index-search.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + + server = RedisVLMCPServer(MCPSettings(config=str(config_path))) + await server.startup() + try: + yield server + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_search_records_routes_to_named_binding(multi_index_server): + knowledge = await search_records( + multi_index_server, + query="science", + index="knowledge", + return_fields=["content", "category"], + ) + assert knowledge["index"] == "knowledge" + assert knowledge["search_type"] == "vector" + assert knowledge["results"] + + tickets = await search_records( + multi_index_server, + query="science", + index="tickets", + return_fields=["content", "category"], + ) + assert tickets["index"] == "tickets" + assert tickets["search_type"] == "fulltext" + assert tickets["results"] + + +@pytest.mark.asyncio +async def test_search_records_requires_index_when_multiple_bindings(multi_index_server): + with pytest.raises(RedisVLMCPError) as exc_info: + await search_records(multi_index_server, query="science") + + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + + +@pytest.mark.asyncio +async def test_search_records_rejects_unknown_index_on_multi_binding( + multi_index_server, +): + with pytest.raises(RedisVLMCPError) as exc_info: + await search_records(multi_index_server, query="science", index="missing") + + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + + +@pytest.mark.asyncio +async def test_search_records_single_binding_echoes_index_when_omitted(started_server): + server = await started_server({"type": "vector"}) + + response = await search_records(server, query="science") + + assert response["index"] == "knowledge" + + @pytest.mark.asyncio async def test_search_records_vector_success_with_pagination_and_projection( started_server, diff --git a/tests/integration/test_mcp/test_server_startup.py b/tests/integration/test_mcp/test_server_startup.py index c278e57ec..ec7de37e6 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 ( @@ -730,3 +731,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/integration/test_mcp/test_upsert_tool.py b/tests/integration/test_mcp/test_upsert_tool.py index ec08d358e..a723b30e4 100644 --- a/tests/integration/test_mcp/test_upsert_tool.py +++ b/tests/integration/test_mcp/test_upsert_tool.py @@ -357,6 +357,130 @@ async def fail_load(*args: Any, **kwargs: Any) -> Any: assert called is False +@pytest.fixture +async def multi_index_upsert_server( + monkeypatch, upsertable_index, fulltext_only_upsert_index, tmp_path, redis_url +): + monkeypatch.setattr( + "redisvl.mcp.server.resolve_vectorizer_class", + lambda class_name: RecordingVectorizer, + ) + + config = { + "server": {"redis_url": redis_url}, + "indexes": { + "knowledge": { + "redis_name": upsertable_index.schema.index.name, + "search": {"type": "vector"}, + "vectorizer": { + "class": "RecordingVectorizer", + "model": "fake-model", + "dims": 3, + }, + "runtime": { + "text_field_name": "content", + "vector_field_name": "embedding", + "default_embed_text_field": "content", + "default_limit": 2, + "max_limit": 5, + "max_upsert_records": 64, + "skip_embedding_if_present": True, + }, + }, + "tickets": { + "redis_name": fulltext_only_upsert_index.schema.index.name, + "read_only": True, + "search": {"type": "fulltext", "params": {"stopwords": None}}, + "runtime": { + "text_field_name": "content", + "vector_field_name": None, + "default_embed_text_field": None, + "default_limit": 2, + "max_limit": 5, + "max_upsert_records": 64, + }, + }, + }, + } + config_path = tmp_path / "multi-index-upsert.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + + server = RedisVLMCPServer(MCPSettings(config=str(config_path))) + await server.startup() + try: + yield server + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_upsert_records_routes_to_named_writable_binding( + multi_index_upsert_server, +): + response = await upsert_records( + multi_index_upsert_server, + index="knowledge", + records=[{"content": "routed document", "category": "science", "rating": 5}], + ) + + assert response["index"] == "knowledge" + assert response["status"] == "success" + assert response["keys_upserted"] == 1 + + +@pytest.mark.asyncio +async def test_upsert_records_requires_index_when_multiple_bindings( + multi_index_upsert_server, +): + with pytest.raises(RedisVLMCPError) as exc_info: + await upsert_records( + multi_index_upsert_server, + records=[{"content": "no index", "category": "science"}], + ) + + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + + +@pytest.mark.asyncio +async def test_upsert_records_rejects_unknown_index_on_multi_binding( + multi_index_upsert_server, +): + with pytest.raises(RedisVLMCPError) as exc_info: + await upsert_records( + multi_index_upsert_server, + index="missing", + records=[{"content": "doc", "category": "science"}], + ) + + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + + +@pytest.mark.asyncio +async def test_upsert_records_rejects_writes_to_read_only_binding( + multi_index_upsert_server, +): + with pytest.raises(RedisVLMCPError, match="read-only") as exc_info: + await upsert_records( + multi_index_upsert_server, + index="tickets", + records=[{"content": "doc", "category": "operations"}], + ) + + assert exc_info.value.code == MCPErrorCode.FORBIDDEN + + +@pytest.mark.asyncio +async def test_upsert_records_single_binding_echoes_index_when_omitted(started_server): + server = await started_server() + + response = await upsert_records( + server, + records=[{"content": "solo document", "category": "science", "rating": 5}], + ) + + assert response["index"] == "knowledge" + + @pytest.mark.asyncio async def test_read_only_mode_excludes_upsert_tool( monkeypatch, upsertable_index, mcp_config_path 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 000000000..0d56b0833 --- /dev/null +++ b/tests/unit/test_mcp/test_list_indexes_tool_unit.py @@ -0,0 +1,231 @@ +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_raises_when_no_bindings(): + # Before startup / after shutdown _bindings is empty; discovery must fail + # loudly rather than return an empty list a client could misread. + server = FakeServer([]) + + with pytest.raises(RuntimeError, match="has not been started"): + list_indexes(server) + + +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_search_tool_unit.py b/tests/unit/test_mcp/test_search_tool_unit.py index aaeae5953..60a887f5c 100644 --- a/tests/unit/test_mcp/test_search_tool_unit.py +++ b/tests/unit/test_mcp/test_search_tool_unit.py @@ -111,8 +111,16 @@ def __init__( self.vectorizer = FakeVectorizer() if include_vectorizer else None self.registered_tools = [] self.native_hybrid_supported = False + self.resolved_index_ids: list[str | None] = [] def resolve_binding(self, index_id=None): + self.resolved_index_ids.append(index_id) + if index_id is not None and index_id != "knowledge": + raise RedisVLMCPError( + f"Unknown index '{index_id}'; available: knowledge", + code=MCPErrorCode.INVALID_REQUEST, + retryable=False, + ) return BindingRuntime( binding_id="knowledge", binding=self.config.indexes["knowledge"], @@ -313,6 +321,7 @@ async def fake_query(query): assert built_queries[0]["normalize_vector_distance"] is False assert built_queries[0]["ef_runtime"] == 42 assert response == { + "index": "knowledge", "search_type": "vector", "offset": 0, "limit": 2, @@ -759,6 +768,64 @@ def test_build_search_tool_description_preserves_schema_order_and_excludes_vecto assert "embedding" not in description.split("Allowed return_fields: ", 1)[1] +@pytest.mark.asyncio +async def test_search_records_defaults_to_sole_binding_when_index_omitted(monkeypatch): + server = FakeServer() + + async def fake_query(query): + return [] + + server.index.query = fake_query + + response = await search_records(server, query="science") + + assert server.resolved_index_ids == [None] + assert response["index"] == "knowledge" + + +@pytest.mark.asyncio +async def test_search_records_routes_to_named_index(monkeypatch): + server = FakeServer() + + async def fake_query(query): + return [] + + server.index.query = fake_query + + response = await search_records(server, query="science", index="knowledge") + + assert server.resolved_index_ids == ["knowledge"] + assert response["index"] == "knowledge" + + +@pytest.mark.asyncio +async def test_search_records_rejects_unknown_index(): + server = FakeServer() + + with pytest.raises(RedisVLMCPError) as exc_info: + await search_records(server, query="science", index="missing") + + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + assert server.resolved_index_ids == ["missing"] + + +def test_register_search_tool_wrapper_exposes_index_param(): + server = FakeServer() + register_search_tool(server, server.index.schema) + + annotations = server.registered_tools[0]["fn"].__annotations__ + assert "index" in annotations + + +def test_build_search_tool_description_appends_routing_note_when_schema_is_ambiguous(): + description = _build_search_tool_description(None) + + assert "list-indexes" in description + assert "`index`" in description + # Per-field hints are omitted because the index is ambiguous. + assert "Object filter fields" not in description + + def test_build_search_tool_description_distinguishes_typed_and_exists_support(): schema = IndexSchema.from_dict( { diff --git a/tests/unit/test_mcp/test_server.py b/tests/unit/test_mcp/test_server.py index cd949bf67..9179bdd5b 100644 --- a/tests/unit/test_mcp/test_server.py +++ b/tests/unit/test_mcp/test_server.py @@ -423,6 +423,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, diff --git a/tests/unit/test_mcp/test_server_unit.py b/tests/unit/test_mcp/test_server_unit.py index 2aa73473e..0bd580d98 100644 --- a/tests/unit/test_mcp/test_server_unit.py +++ b/tests/unit/test_mcp/test_server_unit.py @@ -53,7 +53,9 @@ async def test_probe_native_hybrid_search_false_for_old_redis_py(monkeypatch): assert client.info_calls == 0 -def _binding_runtime(binding_id: str) -> BindingRuntime: +def _binding_runtime( + binding_id: str, *, effective_read_only: bool = False +) -> BindingRuntime: return BindingRuntime( binding_id=binding_id, binding=SimpleNamespace(), @@ -61,7 +63,7 @@ def _binding_runtime(binding_id: str) -> BindingRuntime: schema=SimpleNamespace(), vectorizer=None, supports_native_hybrid_search=False, - effective_read_only=False, + effective_read_only=effective_read_only, ) @@ -140,3 +142,58 @@ async def fake_close_resources(self, *, index, vectorizer): # ...but tool registration is instance-level and must survive teardown, so a # stop/start does not re-register the same tool names on the FastMCP object. assert server._tools_registered is True + + +def _register_tools_with(monkeypatch, bindings: dict) -> list[str]: + """Run _register_tools against the given bindings, returning registered names.""" + registered: list[str] = [] + monkeypatch.setattr( + "redisvl.mcp.server.register_list_indexes_tool", + lambda server: registered.append("list-indexes"), + ) + monkeypatch.setattr( + "redisvl.mcp.server.register_search_tool", + lambda server, schema: registered.append("search-records"), + ) + monkeypatch.setattr( + "redisvl.mcp.server.register_upsert_tool", + lambda server: registered.append("upsert-records"), + ) + + server = RedisVLMCPServer.__new__(RedisVLMCPServer) + server._bindings = bindings + server._tools_registered = False + server.tool = object() + server.mcp_settings = SimpleNamespace(read_only=False) + + server._register_tools() + return registered + + +def test_register_tools_exposes_upsert_when_a_binding_is_writable(monkeypatch): + registered = _register_tools_with( + monkeypatch, + { + "knowledge": _binding_runtime("knowledge", effective_read_only=False), + "tickets": _binding_runtime("tickets", effective_read_only=True), + }, + ) + + assert "upsert-records" in registered + assert "list-indexes" in registered + assert "search-records" in registered + + +def test_register_tools_hides_upsert_when_every_binding_is_read_only(monkeypatch): + registered = _register_tools_with( + monkeypatch, + { + "knowledge": _binding_runtime("knowledge", effective_read_only=True), + "tickets": _binding_runtime("tickets", effective_read_only=True), + }, + ) + + assert "upsert-records" not in registered + # Read paths stay available even when writes are globally disabled. + assert "list-indexes" in registered + assert "search-records" in registered diff --git a/tests/unit/test_mcp/test_upsert_tool_unit.py b/tests/unit/test_mcp/test_upsert_tool_unit.py index 5c2e059dd..2327af903 100644 --- a/tests/unit/test_mcp/test_upsert_tool_unit.py +++ b/tests/unit/test_mcp/test_upsert_tool_unit.py @@ -166,8 +166,16 @@ def __init__( self.vectorizer = vectorizer or FakeVectorizer() if include_vectorizer else None self.registered_tools = [] self.effective_read_only = effective_read_only + self.resolved_index_ids: list[str | None] = [] def resolve_binding(self, index_id=None): + self.resolved_index_ids.append(index_id) + if index_id is not None and index_id != "knowledge": + raise RedisVLMCPError( + f"Unknown index '{index_id}'; available: knowledge", + code=MCPErrorCode.INVALID_REQUEST, + retryable=False, + ) return BindingRuntime( binding_id="knowledge", binding=self.config.indexes["knowledge"], @@ -212,6 +220,7 @@ async def test_upsert_records_generates_missing_vectors_and_serializes_hash_vect ) assert response == { + "index": "knowledge", "status": "success", "keys_upserted": 2, "keys": ["doc:alpha", "doc:beta"], @@ -457,16 +466,61 @@ async def test_upsert_records_surfaces_partial_write_possible_on_backend_failure assert isinstance(exc_info.value.__cause__, RedisError) +@pytest.mark.asyncio +async def test_upsert_records_defaults_to_sole_binding_when_index_omitted(): + server = FakeServer() + + response = await upsert_records(server, records=[{"content": "alpha doc"}]) + + assert server.resolved_index_ids == [None] + assert response["index"] == "knowledge" + + +@pytest.mark.asyncio +async def test_upsert_records_routes_to_named_index(): + server = FakeServer() + + response = await upsert_records( + server, records=[{"content": "alpha doc"}], index="knowledge" + ) + + assert server.resolved_index_ids == ["knowledge"] + assert response["index"] == "knowledge" + + +@pytest.mark.asyncio +async def test_upsert_records_rejects_unknown_index(): + server = FakeServer() + + with pytest.raises(RedisVLMCPError) as exc_info: + await upsert_records( + server, records=[{"content": "alpha doc"}], index="missing" + ) + + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + assert server.resolved_index_ids == ["missing"] + assert server.index.load_calls == [] + + @pytest.mark.asyncio async def test_upsert_records_rejects_writes_to_read_only_binding(): server = FakeServer(effective_read_only=True) - with pytest.raises(RedisVLMCPError) as exc_info: + with pytest.raises(RedisVLMCPError, match="read-only") as exc_info: await upsert_records(server, records=[{"content": "alpha doc"}]) assert exc_info.value.code == MCPErrorCode.FORBIDDEN - # The write is rejected before any backend load is attempted. + # Write policy is enforced before any embedding or backend write. assert server.index.load_calls == [] + assert server.vectorizer.aembed_many_calls == [] + + +def test_register_upsert_tool_wrapper_exposes_index_param(): + server = FakeServer() + register_upsert_tool(server) + + annotations = server.registered_tools[0]["fn"].__annotations__ + assert "index" in annotations def test_register_upsert_tool_uses_default_and_override_descriptions():