Skip to content

Commit c2f30d5

Browse files
vishal-balaclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 45ce686 commit c2f30d5

3 files changed

Lines changed: 187 additions & 5 deletions

File tree

redisvl/mcp/tools/search.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@ def _build_search_tool_description(
5656
"""Build the `search-records` description from static text plus schema hints.
5757
5858
With multiple bindings configured the schema is ambiguous (the caller picks
59-
an index per call via `list-indexes`), so `schema` is None and only the
60-
base description is returned.
59+
an index per call via `list-indexes`), so per-field hints are omitted and a
60+
routing note is appended instead.
6161
"""
6262
description = (base_description or DEFAULT_SEARCH_DESCRIPTION).strip()
6363
if schema is None:
64-
return description
64+
return (
65+
description + " Multiple indexes are configured: call list-indexes "
66+
"first, then pass the chosen index id as the `index` argument."
67+
)
6568

6669
# `exists` is currently accepted for any schema field in the MCP object filter.
6770
exists_fields = [field.name for field in schema.fields.values()]
@@ -427,14 +430,21 @@ async def search_records(
427430
server: Any,
428431
*,
429432
query: str,
433+
index: str | None = None,
430434
limit: int | None = None,
431435
offset: int = 0,
432436
filter: str | dict[str, Any] | None = None,
433437
return_fields: list[str] | None = None,
434438
) -> dict[str, Any]:
435-
"""Execute `search-records` against the selected Redis index binding."""
439+
"""Execute `search-records` against the selected Redis index binding.
440+
441+
``index`` names the logical binding to query. It is optional when exactly
442+
one binding is configured (preserving single-index behavior) and required
443+
when multiple bindings exist. The resolved logical id is echoed back in the
444+
response so multi-index clients can confirm routing.
445+
"""
436446
try:
437-
rt = server.resolve_binding(None)
447+
rt = server.resolve_binding(index)
438448
effective_limit, effective_return_fields = _validate_request(
439449
query=query,
440450
limit=limit,
@@ -458,6 +468,7 @@ async def search_records(
458468
)
459469
sliced_results = raw_results[offset : offset + effective_limit]
460470
return {
471+
"index": rt.binding_id,
461472
"search_type": search_type,
462473
"offset": offset,
463474
"limit": effective_limit,
@@ -485,6 +496,7 @@ def register_search_tool(server: Any, schema: IndexSchema | None) -> None:
485496

486497
async def search_records_tool(
487498
query: str,
499+
index: str | None = None,
488500
limit: int | None = None,
489501
offset: int = 0,
490502
filter: str | dict[str, Any] | None = None,
@@ -497,6 +509,7 @@ async def search_records_tool(
497509
return await search_records(
498510
server,
499511
query=query,
512+
index=index,
500513
limit=limit,
501514
offset=offset,
502515
filter=filter,

tests/integration/test_mcp/test_search_tool.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,108 @@ async def started(search: dict, **kwargs) -> RedisVLMCPServer:
214214
await server.shutdown()
215215

216216

217+
@pytest.fixture
218+
async def multi_index_server(
219+
monkeypatch, searchable_index, fulltext_only_index, tmp_path, redis_url
220+
):
221+
monkeypatch.setattr(
222+
"redisvl.mcp.server.resolve_vectorizer_class",
223+
lambda class_name: FakeVectorizer,
224+
)
225+
226+
config = {
227+
"server": {"redis_url": redis_url},
228+
"indexes": {
229+
"knowledge": {
230+
"redis_name": searchable_index.schema.index.name,
231+
"search": {"type": "vector"},
232+
"vectorizer": {
233+
"class": "FakeVectorizer",
234+
"model": "fake-model",
235+
"dims": 3,
236+
},
237+
"runtime": {
238+
"text_field_name": "content",
239+
"vector_field_name": "embedding",
240+
"default_embed_text_field": "content",
241+
"default_limit": 2,
242+
"max_limit": 5,
243+
},
244+
},
245+
"tickets": {
246+
"redis_name": fulltext_only_index.schema.index.name,
247+
"search": {"type": "fulltext", "params": {"stopwords": None}},
248+
"runtime": {
249+
"text_field_name": "content",
250+
"vector_field_name": None,
251+
"default_embed_text_field": None,
252+
"default_limit": 2,
253+
"max_limit": 5,
254+
},
255+
},
256+
},
257+
}
258+
config_path = tmp_path / "multi-index-search.yaml"
259+
config_path.write_text(yaml.safe_dump(config), encoding="utf-8")
260+
261+
server = RedisVLMCPServer(MCPSettings(config=str(config_path)))
262+
await server.startup()
263+
try:
264+
yield server
265+
finally:
266+
await server.shutdown()
267+
268+
269+
@pytest.mark.asyncio
270+
async def test_search_records_routes_to_named_binding(multi_index_server):
271+
knowledge = await search_records(
272+
multi_index_server,
273+
query="science",
274+
index="knowledge",
275+
return_fields=["content", "category"],
276+
)
277+
assert knowledge["index"] == "knowledge"
278+
assert knowledge["search_type"] == "vector"
279+
assert knowledge["results"]
280+
281+
tickets = await search_records(
282+
multi_index_server,
283+
query="science",
284+
index="tickets",
285+
return_fields=["content", "category"],
286+
)
287+
assert tickets["index"] == "tickets"
288+
assert tickets["search_type"] == "fulltext"
289+
assert tickets["results"]
290+
291+
292+
@pytest.mark.asyncio
293+
async def test_search_records_requires_index_when_multiple_bindings(multi_index_server):
294+
with pytest.raises(RedisVLMCPError) as exc_info:
295+
await search_records(multi_index_server, query="science")
296+
297+
assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST
298+
299+
300+
@pytest.mark.asyncio
301+
async def test_search_records_rejects_unknown_index_on_multi_binding(
302+
multi_index_server,
303+
):
304+
with pytest.raises(RedisVLMCPError) as exc_info:
305+
await search_records(multi_index_server, query="science", index="missing")
306+
307+
assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST
308+
309+
310+
@pytest.mark.asyncio
311+
async def test_search_records_single_binding_echoes_index_when_omitted(started_server):
312+
server = await started_server({"type": "vector"})
313+
314+
response = await search_records(server, query="science")
315+
316+
assert response["index"] == "knowledge"
317+
318+
217319
@pytest.mark.asyncio
218320
async def test_search_records_vector_success_with_pagination_and_projection(
219321
started_server,

tests/unit/test_mcp/test_search_tool_unit.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,16 @@ def __init__(
111111
self.vectorizer = FakeVectorizer() if include_vectorizer else None
112112
self.registered_tools = []
113113
self.native_hybrid_supported = False
114+
self.resolved_index_ids: list[str | None] = []
114115

115116
def resolve_binding(self, index_id=None):
117+
self.resolved_index_ids.append(index_id)
118+
if index_id is not None and index_id != "knowledge":
119+
raise RedisVLMCPError(
120+
f"Unknown index '{index_id}'; available: knowledge",
121+
code=MCPErrorCode.INVALID_REQUEST,
122+
retryable=False,
123+
)
116124
return BindingRuntime(
117125
binding_id="knowledge",
118126
binding=self.config.indexes["knowledge"],
@@ -313,6 +321,7 @@ async def fake_query(query):
313321
assert built_queries[0]["normalize_vector_distance"] is False
314322
assert built_queries[0]["ef_runtime"] == 42
315323
assert response == {
324+
"index": "knowledge",
316325
"search_type": "vector",
317326
"offset": 0,
318327
"limit": 2,
@@ -759,6 +768,64 @@ def test_build_search_tool_description_preserves_schema_order_and_excludes_vecto
759768
assert "embedding" not in description.split("Allowed return_fields: ", 1)[1]
760769

761770

771+
@pytest.mark.asyncio
772+
async def test_search_records_defaults_to_sole_binding_when_index_omitted(monkeypatch):
773+
server = FakeServer()
774+
775+
async def fake_query(query):
776+
return []
777+
778+
server.index.query = fake_query
779+
780+
response = await search_records(server, query="science")
781+
782+
assert server.resolved_index_ids == [None]
783+
assert response["index"] == "knowledge"
784+
785+
786+
@pytest.mark.asyncio
787+
async def test_search_records_routes_to_named_index(monkeypatch):
788+
server = FakeServer()
789+
790+
async def fake_query(query):
791+
return []
792+
793+
server.index.query = fake_query
794+
795+
response = await search_records(server, query="science", index="knowledge")
796+
797+
assert server.resolved_index_ids == ["knowledge"]
798+
assert response["index"] == "knowledge"
799+
800+
801+
@pytest.mark.asyncio
802+
async def test_search_records_rejects_unknown_index():
803+
server = FakeServer()
804+
805+
with pytest.raises(RedisVLMCPError) as exc_info:
806+
await search_records(server, query="science", index="missing")
807+
808+
assert exc_info.value.code == MCPErrorCode.INVALID_REQUEST
809+
assert server.resolved_index_ids == ["missing"]
810+
811+
812+
def test_register_search_tool_wrapper_exposes_index_param():
813+
server = FakeServer()
814+
register_search_tool(server, server.index.schema)
815+
816+
annotations = server.registered_tools[0]["fn"].__annotations__
817+
assert "index" in annotations
818+
819+
820+
def test_build_search_tool_description_appends_routing_note_when_schema_is_ambiguous():
821+
description = _build_search_tool_description(None)
822+
823+
assert "list-indexes" in description
824+
assert "`index`" in description
825+
# Per-field hints are omitted because the index is ambiguous.
826+
assert "Object filter fields" not in description
827+
828+
762829
def test_build_search_tool_description_distinguishes_typed_and_exists_support():
763830
schema = IndexSchema.from_dict(
764831
{

0 commit comments

Comments
 (0)