Skip to content

Commit 6bd9d30

Browse files
vishal-balaclaude
andauthored
feat(mcp): add list-indexes discovery tool (RAAE-1605) (#630)
> Stacked on #629 (RAAE-1604). Review/merge that first; this PR targets the 1604 branch so the diff is scoped to the discovery tool. ## Motivation Once a single MCP server can expose multiple logical indexes ([RAAE-1604](https://redislabs.atlassian.net/browse/RAAE-1604)), clients need a lightweight way to discover what's available so they can pick the right index instead of guessing. This PR ([RAAE-1605](https://redislabs.atlassian.net/browse/RAAE-1605)) adds an always-registered, read-only `list-indexes` tool for exactly that, and it grounds discovery in the schema the server already inspected at startup rather than asking users to re-declare field metadata in config. ## Implementation The new tool (`redisvl/mcp/tools/list_indexes.py`) returns one entry per configured binding, in configured order: the logical `id`, an optional `description`, an `upsert_available` flag, the shared filterable `fields`, and — only when explicitly configured — a `limits` object. `upsert_available` is simply `not effective_read_only`, so it already reflects both the global `--read-only` flag and the per-index `read_only` policy resolved at startup. The `fields` list is built from the binding's effective (inspected + overridden) schema that already lives on its `BindingRuntime`, so the output stays consistent with what the index actually contains; the vector field and the configured default embed-source text field are omitted because they are implementation inputs rather than fields a client would filter on. The Redis index name (`redis_name`) is deliberately never exposed. Limits are included only when the operator set them explicitly — detected via the runtime model's `model_fields_set` — so defaults don't masquerade as deliberate overrides; per the contract this covers `max_limit` and `max_upsert_records`. The tool is registered unconditionally during the server's tool registration (alongside `search-records` and the conditionally-registered `upsert-records`) and is gated by the same read scope as search when auth is enabled, since it is read-only. - Output is deterministic and ordered by configured binding. - No new configuration surface or settings are required. ## Verification - `mypy` clean; `black`/`isort` formatted. - New unit coverage: field omission (vector + embed-source), description/limits inclusion rules, `redis_name` secrecy, read-only reflection, single- and multi-binding output, and tool registration. - New integration test starts a real two-binding server (one vector, one fulltext) and asserts the discovered fields come from the inspected schema and follow the omission rules. - Full MCP suite green: 178 unit + 45 integration (2 skipped on Redis-version gates) against Redis 8. 🤖 Generated with [Claude Code](https://claude.com/claude-code) [RAAE-1604]: https://redislabs.atlassian.net/browse/RAAE-1604?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [RAAE-1605]: https://redislabs.atlassian.net/browse/RAAE-1605?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Read-only discovery metadata with no new config; behavior is additive and gated like existing search tools when auth is enabled. > > **Overview** > Adds a read-only **`list-indexes`** MCP tool so clients can discover logical indexes on multi-binding servers before calling **`search-records`** or **`upsert-records`**. > > The tool is **always registered** during `_register_tools` (alongside search; upsert remains conditional). Each binding is returned in config order with **`id`**, optional **`description`**, **`upsert_available`** (`not effective_read_only`), filterable **`fields`** from the startup-inspected schema (vector and default embed-source text omitted), and **`limits`** only when **`max_limit`** / **`max_upsert_records`** were explicitly set. **`redis_name`** is never exposed; empty bindings raise **`RuntimeError`** like other pre-startup paths. When auth is on, the tool uses the same **read scope** as search. > > Unit and integration tests cover payload rules, registration, and a two-binding startup scenario. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 45ce686. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f80e98f commit 6bd9d30

5 files changed

Lines changed: 409 additions & 0 deletions

File tree

redisvl/mcp/server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError
1616
from redisvl.mcp.runtime import BindingRuntime
1717
from redisvl.mcp.settings import MCPSettings
18+
from redisvl.mcp.tools.list_indexes import register_list_indexes_tool
1819
from redisvl.mcp.tools.search import register_search_tool
1920
from redisvl.mcp.tools.upsert import register_upsert_tool
2021
from redisvl.redis.connection import RedisConnectionFactory, is_version_gte
@@ -246,6 +247,8 @@ def _register_tools(self) -> None:
246247
if len(self._bindings) == 1:
247248
search_schema = next(iter(self._bindings.values())).schema
248249

250+
# Discovery is always available so clients can enumerate indexes.
251+
register_list_indexes_tool(self)
249252
register_search_tool(self, search_schema)
250253
if not self.mcp_settings.read_only:
251254
register_upsert_tool(self)

redisvl/mcp/tools/list_indexes.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from typing import TYPE_CHECKING, Any
2+
3+
from redisvl.mcp.auth import ensure_tool_scope
4+
from redisvl.mcp.runtime import BindingRuntime
5+
6+
if TYPE_CHECKING:
7+
from redisvl.mcp.server import RedisVLMCPServer
8+
9+
DEFAULT_LIST_INDEXES_DESCRIPTION = (
10+
"List the logical indexes configured on this server. Each entry reports the "
11+
"index id, an optional description, whether upsert is available, the "
12+
"filterable fields discovered from the index, and any explicitly configured "
13+
"limits. Call this first on a multi-index server to choose the correct "
14+
"index for search-records or upsert-records."
15+
)
16+
17+
# Runtime limits surfaced to clients, included only when explicitly configured.
18+
_LIMIT_FIELDS = ("max_limit", "max_upsert_records")
19+
20+
21+
def _binding_fields(binding_runtime: BindingRuntime) -> list[dict[str, str]]:
22+
"""Return a binding's shared filterable fields from its inspected schema.
23+
24+
The vector field and the configured default embed-source text field are
25+
omitted: they are implementation inputs, not fields a client filters on.
26+
"""
27+
embed_source = binding_runtime.binding.runtime.default_embed_text_field
28+
fields: list[dict[str, str]] = []
29+
for field in binding_runtime.schema.fields.values():
30+
field_type = str(getattr(field.type, "value", field.type))
31+
if field_type.lower() == "vector":
32+
continue
33+
if field.name == embed_source:
34+
continue
35+
fields.append({"name": field.name, "type": field_type})
36+
return fields
37+
38+
39+
def _binding_limits(binding_runtime: BindingRuntime) -> dict[str, int]:
40+
"""Return runtime limits that were explicitly configured for the binding.
41+
42+
Defaults are intentionally excluded so the output reflects deliberate
43+
overrides rather than implementation defaults.
44+
"""
45+
runtime = binding_runtime.binding.runtime
46+
configured = runtime.model_fields_set
47+
return {
48+
name: getattr(runtime, name) for name in _LIMIT_FIELDS if name in configured
49+
}
50+
51+
52+
def _describe_binding(binding_runtime: BindingRuntime) -> dict[str, Any]:
53+
"""Build the deterministic discovery payload for a single binding."""
54+
entry: dict[str, Any] = {"id": binding_runtime.binding_id}
55+
if binding_runtime.binding.description is not None:
56+
entry["description"] = binding_runtime.binding.description
57+
# Reflects both global read-only and the per-index read_only policy.
58+
entry["upsert_available"] = not binding_runtime.effective_read_only
59+
entry["fields"] = _binding_fields(binding_runtime)
60+
limits = _binding_limits(binding_runtime)
61+
if limits:
62+
entry["limits"] = limits
63+
return entry
64+
65+
66+
def list_indexes(server: "RedisVLMCPServer") -> dict[str, Any]:
67+
"""Return the discovery payload for every configured binding.
68+
69+
The Redis index name (``redis_name``) is intentionally never exposed.
70+
"""
71+
# Mirror resolve_binding: with no bindings the server is not started (or has
72+
# been torn down), so fail loudly rather than return an empty list that a
73+
# client could misread as "no indexes configured".
74+
if not server._bindings:
75+
raise RuntimeError("MCP server has not been started")
76+
return {
77+
"indexes": [
78+
_describe_binding(binding_runtime)
79+
for binding_runtime in server._bindings.values()
80+
],
81+
}
82+
83+
84+
def register_list_indexes_tool(server: "RedisVLMCPServer") -> None:
85+
"""Register the always-available, read-only `list-indexes` MCP tool."""
86+
87+
async def list_indexes_tool():
88+
"""FastMCP wrapper for the `list-indexes` tool."""
89+
auth_config = getattr(server, "auth_config", None)
90+
read_scope = auth_config.read_scope if auth_config is not None else None
91+
ensure_tool_scope(server, read_scope)
92+
return list_indexes(server)
93+
94+
server.tool(name="list-indexes", description=DEFAULT_LIST_INDEXES_DESCRIPTION)(
95+
list_indexes_tool
96+
)

tests/integration/test_mcp/test_server_startup.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError
1010
from redisvl.mcp.server import RedisVLMCPServer
1111
from redisvl.mcp.settings import MCPSettings
12+
from redisvl.mcp.tools.list_indexes import list_indexes
1213
from redisvl.redis.connection import is_version_gte
1314
from redisvl.schema import IndexSchema
1415
from tests.conftest import (
@@ -730,3 +731,78 @@ async def test_server_startup_fails_when_one_binding_is_invalid(
730731

731732
assert server._lifecycle_state.name == "STOPPED"
732733
assert server._bindings == {}
734+
735+
736+
@pytest.mark.asyncio
737+
async def test_list_indexes_derives_fields_from_inspected_schema(
738+
monkeypatch, existing_index, multi_index_config_path
739+
):
740+
knowledge = await existing_index(index_name="mcp-list-knowledge")
741+
tickets = await existing_index(index_name="mcp-list-tickets")
742+
monkeypatch.setattr(
743+
"redisvl.mcp.server.resolve_vectorizer_class",
744+
lambda class_name: FakeVectorizer,
745+
)
746+
server = RedisVLMCPServer(
747+
MCPSettings(
748+
config=multi_index_config_path(
749+
{
750+
# Vector binding: content is the embed source.
751+
"knowledge": {
752+
"redis_name": knowledge.name,
753+
"description": "Product docs",
754+
"vectorizer": {
755+
"class": "FakeVectorizer",
756+
"model": "fake-model",
757+
"dims": 3,
758+
},
759+
"search": {"type": "vector"},
760+
"runtime": {
761+
"text_field_name": "content",
762+
"vector_field_name": "embedding",
763+
"default_embed_text_field": "content",
764+
"max_limit": 25,
765+
},
766+
},
767+
# Fulltext binding: no embed source, read-only.
768+
"tickets": {
769+
"redis_name": tickets.name,
770+
"read_only": True,
771+
"search": {"type": "fulltext"},
772+
"runtime": {"text_field_name": "content"},
773+
},
774+
}
775+
)
776+
)
777+
)
778+
779+
await server.startup()
780+
781+
try:
782+
result = list_indexes(server)
783+
indexes = {entry["id"]: entry for entry in result["indexes"]}
784+
785+
# Both bindings are discoverable; redis_name is never leaked.
786+
assert set(indexes) == {"knowledge", "tickets"}
787+
for entry in indexes.values():
788+
assert "redis_name" not in entry
789+
assert knowledge.name not in entry.values()
790+
assert tickets.name not in entry.values()
791+
792+
# Fields come from the inspected schema. The vector field is always
793+
# omitted; the embed-source field is omitted only where configured.
794+
knowledge_fields = {f["name"] for f in indexes["knowledge"]["fields"]}
795+
tickets_fields = {f["name"] for f in indexes["tickets"]["fields"]}
796+
assert "embedding" not in knowledge_fields
797+
assert "embedding" not in tickets_fields
798+
assert "content" not in knowledge_fields # embed source omitted
799+
assert "content" in tickets_fields # no embed source configured
800+
801+
# Per-index write policy and explicit limits are reflected.
802+
assert indexes["knowledge"]["upsert_available"] is True
803+
assert indexes["tickets"]["upsert_available"] is False
804+
assert indexes["knowledge"]["limits"] == {"max_limit": 25}
805+
assert "limits" not in indexes["tickets"]
806+
assert indexes["knowledge"]["description"] == "Product docs"
807+
finally:
808+
await server.shutdown()

0 commit comments

Comments
 (0)