From 4bace78d55c604a952a59dd5e1f9df14ec621345 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 16 Jun 2026 10:48:54 +0200 Subject: [PATCH 1/7] feat(mcp): add list-indexes discovery tool (RAAE-1605) Add an always-registered, read-only `list-indexes` MCP tool so clients can enumerate the logical indexes a multi-index server exposes and choose the right one before calling search-records or upsert-records. For each configured binding the tool returns the logical id, an optional description, whether upsert is available (reflecting both the global --read-only flag and the per-index read_only policy), the shared filterable fields, and any explicitly configured runtime limits. Fields are derived from the binding's already-inspected effective schema rather than user-declared metadata; the vector field and the configured default embed-source text field are omitted because they are implementation inputs, not things a client filters on. The Redis index name (redis_name) is never exposed. Limits are surfaced only when explicitly set in config (detected via the runtime model's model_fields_set), so the output reflects deliberate overrides rather than defaults. - New redisvl/mcp/tools/list_indexes.py with list_indexes() + register_list_indexes_tool(). - Registered unconditionally in the server's tool registration, alongside search/upsert. - Output is deterministic and ordered by configured binding. - TDD: unit coverage for field omission, description/limits inclusion rules, redis_name secrecy, read-only reflection, and registration; integration test verifying fields are derived from the inspected schema across a vector and a fulltext binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/server.py | 3 + redisvl/mcp/tools/list_indexes.py | 85 +++++++ .../test_mcp/test_server_startup.py | 76 ++++++ .../test_mcp/test_list_indexes_tool_unit.py | 222 ++++++++++++++++++ tests/unit/test_mcp/test_server.py | 3 + 5 files changed, 389 insertions(+) create mode 100644 redisvl/mcp/tools/list_indexes.py create mode 100644 tests/unit/test_mcp/test_list_indexes_tool_unit.py diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index b8955c483..899bb6f7b 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,6 +247,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 000000000..be754d596 --- /dev/null +++ b/redisvl/mcp/tools/list_indexes.py @@ -0,0 +1,85 @@ +from typing import Any + +from redisvl.mcp.auth import ensure_tool_scope + +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(rt: Any) -> 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 = rt.binding.runtime.default_embed_text_field + fields: list[dict[str, str]] = [] + for field in rt.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(rt: Any) -> 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 = rt.binding.runtime + configured = runtime.model_fields_set + return { + name: getattr(runtime, name) for name in _LIMIT_FIELDS if name in configured + } + + +def _describe_binding(rt: Any) -> dict[str, Any]: + """Build the deterministic discovery payload for a single binding.""" + entry: dict[str, Any] = {"id": rt.binding_id} + if rt.binding.description is not None: + entry["description"] = rt.binding.description + # Reflects both global read-only and the per-index read_only policy. + entry["upsert_available"] = not rt.effective_read_only + entry["fields"] = _binding_fields(rt) + limits = _binding_limits(rt) + if limits: + entry["limits"] = limits + return entry + + +def list_indexes(server: Any) -> 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(rt) for rt in server._bindings.values()], + } + + +def register_list_indexes_tool(server: Any) -> 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 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/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..10fb933eb --- /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 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, From b99583335a6abe5f21c25a1278d39ded14d044dd Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 25 Jun 2026 14:01:50 +0200 Subject: [PATCH 2/7] refactor(mcp): type list-indexes helpers with BindingRuntime Replace the opaque `rt: Any` parameters in list_indexes.py with the concrete `BindingRuntime` type and the clearer name `binding_runtime`, and type the `server` parameters as `RedisVLMCPServer` (via a TYPE_CHECKING import to avoid the server<->tools import cycle). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/tools/list_indexes.py | 39 ++++++++++++++++++------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/redisvl/mcp/tools/list_indexes.py b/redisvl/mcp/tools/list_indexes.py index be754d596..919c14dac 100644 --- a/redisvl/mcp/tools/list_indexes.py +++ b/redisvl/mcp/tools/list_indexes.py @@ -1,6 +1,10 @@ -from typing import Any +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 " @@ -14,15 +18,15 @@ _LIMIT_FIELDS = ("max_limit", "max_upsert_records") -def _binding_fields(rt: Any) -> list[dict[str, str]]: +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 = rt.binding.runtime.default_embed_text_field + embed_source = binding_runtime.binding.runtime.default_embed_text_field fields: list[dict[str, str]] = [] - for field in rt.schema.fields.values(): + for field in binding_runtime.schema.fields.values(): field_type = str(getattr(field.type, "value", field.type)) if field_type.lower() == "vector": continue @@ -32,44 +36,47 @@ def _binding_fields(rt: Any) -> list[dict[str, str]]: return fields -def _binding_limits(rt: Any) -> dict[str, int]: +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 = rt.binding.runtime + 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(rt: Any) -> dict[str, Any]: +def _describe_binding(binding_runtime: BindingRuntime) -> dict[str, Any]: """Build the deterministic discovery payload for a single binding.""" - entry: dict[str, Any] = {"id": rt.binding_id} - if rt.binding.description is not None: - entry["description"] = rt.binding.description + 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 rt.effective_read_only - entry["fields"] = _binding_fields(rt) - limits = _binding_limits(rt) + 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: Any) -> dict[str, Any]: +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(rt) for rt in server._bindings.values()], + "indexes": [ + _describe_binding(binding_runtime) + for binding_runtime in server._bindings.values() + ], } -def register_list_indexes_tool(server: Any) -> None: +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) From 227698513d14798d369e56a72a72a1131a38dce2 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 30 Jun 2026 13:34:13 +0200 Subject: [PATCH 3/7] refactor(mcp): drop dead description override and harden auth scope read (RAAE-1605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on list_indexes.py: - Remove the `tool_list_indexes_description` override: that setting does not exist on MCPSettings (only tool_search/upsert_description do), so the getattr branch was always None and never fired. Pass the default description constant directly. - Read the read scope as `auth_config.read_scope` (a typed field on MCPAuthConfig) instead of a silent `getattr(..., "read_scope", None)`. The old form would fail open — silently yielding None and skipping auth enforcement — if the field were ever renamed; direct access fails loud. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/tools/list_indexes.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/redisvl/mcp/tools/list_indexes.py b/redisvl/mcp/tools/list_indexes.py index 919c14dac..d7d2ee8e8 100644 --- a/redisvl/mcp/tools/list_indexes.py +++ b/redisvl/mcp/tools/list_indexes.py @@ -78,15 +78,14 @@ def list_indexes(server: "RedisVLMCPServer") -> dict[str, Any]: 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) + 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=description)(list_indexes_tool) + server.tool(name="list-indexes", description=DEFAULT_LIST_INDEXES_DESCRIPTION)( + list_indexes_tool + ) From 45ce686e0ef11a4872cab82836aed3eccf5be659 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Wed, 1 Jul 2026 11:19:13 +0200 Subject: [PATCH 4/7] fix(mcp): fail loudly when list-indexes runs with no bindings (RAAE-1605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_indexes registered the tool instance-level, so it can still be called before startup or after shutdown when _bindings is empty. Returning {"indexes": []} there is misleading — a client reads it as "no indexes configured" rather than "server not ready". Guard with the same "MCP server has not been started" RuntimeError that resolve_binding raises. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/tools/list_indexes.py | 5 +++++ tests/unit/test_mcp/test_list_indexes_tool_unit.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/redisvl/mcp/tools/list_indexes.py b/redisvl/mcp/tools/list_indexes.py index d7d2ee8e8..58a6adefc 100644 --- a/redisvl/mcp/tools/list_indexes.py +++ b/redisvl/mcp/tools/list_indexes.py @@ -68,6 +68,11 @@ def list_indexes(server: "RedisVLMCPServer") -> dict[str, Any]: 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) diff --git a/tests/unit/test_mcp/test_list_indexes_tool_unit.py b/tests/unit/test_mcp/test_list_indexes_tool_unit.py index 10fb933eb..0d56b0833 100644 --- a/tests/unit/test_mcp/test_list_indexes_tool_unit.py +++ b/tests/unit/test_mcp/test_list_indexes_tool_unit.py @@ -98,6 +98,15 @@ def decorator(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()]) From c2f30d5f29c10ca25ea451ee8a3fdd581cc3b70f Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 16 Jun 2026 11:00:46 +0200 Subject: [PATCH 5/7] feat(mcp): add index routing to search-records (RAAE-1606) Add an optional `index` argument to the search-records tool so a single multi-binding MCP server can target a specific logical index. The argument is optional when exactly one binding is configured (preserving single-index behavior) and resolves through the same resolve_binding routing used elsewhere, so an omitted index on a multi-binding server and unknown ids both surface as invalid_request. The resolved logical id is echoed back as the `index` field in the response. - Expose `index` on the FastMCP wrapper param list. - Append a routing note to the tool description when the schema is ambiguous (multiple bindings) directing clients to call list-indexes first. - Add unit + integration coverage for routing, omitted-index rejection, unknown ids, and single-binding backward compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/tools/search.py | 23 +++- .../integration/test_mcp/test_search_tool.py | 102 ++++++++++++++++++ tests/unit/test_mcp/test_search_tool_unit.py | 67 ++++++++++++ 3 files changed, 187 insertions(+), 5 deletions(-) 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/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/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( { From a333c135ec8f1bc90b2e3efd2a6f92a3de0df6ec Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 16 Jun 2026 11:11:49 +0200 Subject: [PATCH 6/7] feat(mcp): add index routing and per-index write policy to upsert-records (RAAE-1607) Add an optional `index` argument to the upsert-records tool so a multi-binding MCP server can target a specific logical index for writes. As with search, the argument is optional on single-binding servers and required when multiple bindings exist; resolution flows through the shared resolve_binding routing so an omitted index on a multi-binding server and unknown ids both surface as invalid_request. The resolved logical id is echoed back as the `index` field in the response, and the selected binding's embedding, runtime limits, and schema validation are used throughout. Write availability is now enforced at two levels. The upsert tool is registered only when at least one binding is writable, so an all-read-only server (whether from global read-only mode or every binding's own read_only policy) does not advertise the tool at all. When the tool is registered, a per-call check rejects writes to any individual read-only binding with invalid_request before any embedding or backend write occurs, so a writable server can still protect specific indexes. - Expose `index` on the FastMCP wrapper param list. - Refine the registration gate from "global read-only off" to "any binding writable" using effective_read_only, which folds in both global and per-index read-only. - Add unit + integration coverage for routing, omitted-index rejection, unknown ids, read-only rejection, the registration gate, and single-binding backward compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/server.py | 6 +- redisvl/mcp/tools/upsert.py | 25 +++- .../integration/test_mcp/test_upsert_tool.py | 124 ++++++++++++++++++ tests/unit/test_mcp/test_server_unit.py | 61 ++++++++- tests/unit/test_mcp/test_upsert_tool_unit.py | 60 ++++++++- 5 files changed, 263 insertions(+), 13 deletions(-) diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index 899bb6f7b..d3d03491f 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -250,7 +250,11 @@ def _register_tools(self) -> None: # 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/upsert.py b/redisvl/mcp/tools/upsert.py index c0d3b7cc5..94231a1c3 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", - code=MCPErrorCode.FORBIDDEN, + f"index '{rt.binding_id}' is read-only", + code=MCPErrorCode.INVALID_REQUEST, 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_upsert_tool.py b/tests/integration/test_mcp/test_upsert_tool.py index ec08d358e..7c1e71a88 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.INVALID_REQUEST + + +@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_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..01f340713 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. + assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + # 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(): From 98aded71a950a23a3173d08c9f33d1205e0da8bb Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Mon, 29 Jun 2026 15:12:12 +0200 Subject: [PATCH 7/7] fix(mcp): use FORBIDDEN for read-only upsert rejection (RAAE-1607) Align the read-only write rejection with the FORBIDDEN error code used on the 1604 branch, instead of INVALID_REQUEST. A read-only binding is a permission policy denial (the request is well-formed but not allowed), so FORBIDDEN is the correct category; INVALID_REQUEST remains for malformed/unroutable requests (unknown index, omitted index, bad shapes). Keeps the two stacked branches consistent so they reconcile cleanly when the stack is collected. Co-Authored-By: Claude Opus 4.8 (1M context) --- redisvl/mcp/tools/upsert.py | 2 +- tests/integration/test_mcp/test_upsert_tool.py | 2 +- tests/unit/test_mcp/test_upsert_tool_unit.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/redisvl/mcp/tools/upsert.py b/redisvl/mcp/tools/upsert.py index 94231a1c3..4137c9a1a 100644 --- a/redisvl/mcp/tools/upsert.py +++ b/redisvl/mcp/tools/upsert.py @@ -266,7 +266,7 @@ async def upsert_records( if rt.effective_read_only: raise RedisVLMCPError( f"index '{rt.binding_id}' is read-only", - code=MCPErrorCode.INVALID_REQUEST, + code=MCPErrorCode.FORBIDDEN, retryable=False, ) index_obj = rt.index diff --git a/tests/integration/test_mcp/test_upsert_tool.py b/tests/integration/test_mcp/test_upsert_tool.py index 7c1e71a88..a723b30e4 100644 --- a/tests/integration/test_mcp/test_upsert_tool.py +++ b/tests/integration/test_mcp/test_upsert_tool.py @@ -466,7 +466,7 @@ async def test_upsert_records_rejects_writes_to_read_only_binding( records=[{"content": "doc", "category": "operations"}], ) - assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + assert exc_info.value.code == MCPErrorCode.FORBIDDEN @pytest.mark.asyncio diff --git a/tests/unit/test_mcp/test_upsert_tool_unit.py b/tests/unit/test_mcp/test_upsert_tool_unit.py index 01f340713..2327af903 100644 --- a/tests/unit/test_mcp/test_upsert_tool_unit.py +++ b/tests/unit/test_mcp/test_upsert_tool_unit.py @@ -509,7 +509,7 @@ async def test_upsert_records_rejects_writes_to_read_only_binding(): with pytest.raises(RedisVLMCPError, match="read-only") as exc_info: await upsert_records(server, records=[{"content": "alpha doc"}]) - assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST + assert exc_info.value.code == MCPErrorCode.FORBIDDEN # Write policy is enforced before any embedding or backend write. assert server.index.load_calls == [] assert server.vectorizer.aembed_many_calls == []