Skip to content

Commit f80e98f

Browse files
vishal-balaclaude
andauthored
feat(mcp): support multiple active index bindings (RAAE-1604) (#629)
## Motivation The RedisVL MCP server currently binds to exactly one Redis index per process. That single-binding assumption is enforced by a config validator and baked throughout the codebase — single-resource server state, single-binding convenience accessors on `MCPConfig`, and the search/upsert tools. Before the server can expose multiple logical indexes from a single endpoint ([RAAE-1603](https://redislabs.atlassian.net/browse/RAAE-1603)), that assumption has to be removed and replaced with a real multi-binding model. This PR ([RAAE-1604](https://redislabs.atlassian.net/browse/RAAE-1604)) does exactly that, and nothing more: it reshapes the configuration and runtime model so the server can start, inspect, validate, and serve one *or many* bindings, while keeping existing single-index configs and callers behaving identically. It is the foundation the rest of the epic (discovery via `list-indexes`, index routing on `search-records`/`upsert-records`, docs) builds on, so it intentionally does not yet add any new request parameters or tools. ## Implementation The core of the change is a new immutable `BindingRuntime` (in `redisvl/mcp/runtime.py`) that bundles everything a tool call needs for one logical index: the binding config, the connected `AsyncSearchIndex`, its effective (inspected + overridden) schema, an optional vectorizer, the resolved native-hybrid-search capability, and the effective read-only flag. The server now holds a `dict[str, BindingRuntime]` keyed by logical id instead of a single set of `_index`/`_vectorizer` fields. Startup iterates every configured binding and inspects, validates, and initializes each one independently — each binding owns its own Redis client — with all-or-nothing teardown so a single bad binding fails startup cleanly without leaking connections. On the config side, the "exactly one configured index binding" validator is gone (we now simply require at least one binding with non-blank ids), and the schema-inspection, runtime-mapping, and search-validation methods move from `MCPConfig` onto `MCPIndexBindingConfig` where they naturally belong per binding. The single-binding convenience accessors on `MCPConfig` are removed. Each binding gains optional `description` and `read_only` fields, and a binding's effective write availability is computed as global `--read-only` OR the per-index `read_only`. Tool resolution goes through a new `server.resolve_binding(index_id)` helper that defaults to the sole binding when one is configured (preserving backward compatibility) and returns an `invalid_request` error when an index is omitted with multiple bindings configured or when an unknown id is given. The search and upsert tools were re-threaded to operate on a resolved `BindingRuntime` rather than reaching into single-binding server accessors. Additional notes: - Native-hybrid-search support is now probed eagerly per binding at startup and stored on the `BindingRuntime`, replacing the previous lazy single-index cache. - The concurrency semaphore is a single process-wide ceiling sized from the maximum `max_concurrency` across bindings; the request timeout is sourced per-binding and passed explicitly into `run_guarded`. - `get_index()` / `get_vectorizer()` are retained as thin convenience wrappers over `resolve_binding(None)`. - Implemented test-first: new coverage for multi-binding config loading, `description`/`read_only` defaults, `resolve_binding` routing semantics, semaphore sizing, per-binding teardown, and three integration tests (multi-binding startup, global read-only override, and a single invalid binding failing startup), alongside the updated single-index tests that confirm backward compatibility. ## Verification - `mypy` clean across all source files; `black`/`isort` formatted. - 182 MCP unit tests pass. - 44 MCP integration tests pass (2 skipped on Redis-version gates) against Redis 8. 🤖 Generated with [Claude Code](https://claude.com/claude-code) [RAAE-1603]: https://redislabs.atlassian.net/browse/RAAE-1603?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [RAAE-1604]: https://redislabs.atlassian.net/browse/RAAE-1604?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > MCP startup/teardown and binding resolution affect how indexes are served; the required `run_guarded(..., timeout_seconds=)` change breaks custom MCP extensions that call it directly. > > **Overview** > **MCP** moves from a single enforced index binding to a **`dict` of `BindingRuntime`** entries: startup inspects and initializes each configured index independently (own client, vectorizer, hybrid probe, effective read-only), with **`resolve_binding(index_id)`** defaulting when only one index is configured and rejecting ambiguous or unknown ids. Config drops the “exactly one binding” rule and **`MCPConfig`** convenience accessors; per-binding **`description`**, **`read_only`**, and schema/search helpers live on **`MCPIndexBindingConfig`**. Search/upsert tools read from the resolved runtime; **`run_guarded`** now requires **`timeout_seconds=`** per binding (breaking for direct callers). > > Also in this release: **`SearchIndex.drop_keys`** uses **`UNLINK`** instead of **`DEL`**; semantic router **`delete()`** removes the standalone route-config key; **`sql-redis>=0.7.1`** with docs for **`hybrid_vector_search` / FT.HYBRID**; auto-release publishes to PyPI via **`pypa/gh-action-pypi-publish`** with OIDC; version **0.22.0**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bd2a28a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --- ### ⚠️ Breaking change for downstream authors `RedisVLMCPServer.run_guarded(operation_name, awaitable)` now requires a keyword-only `timeout_seconds` argument (sourced from each binding's `request_timeout_seconds`). Any code that subclasses `RedisVLMCPServer` or calls `run_guarded` directly (custom tools/plugins) must update its call sites to pass `timeout_seconds=`, otherwise it raises `TypeError: run_guarded() missing 1 required keyword-only argument: 'timeout_seconds'` at call time. This repository has no CHANGELOG file, so this note serves as the migration callout (per review feedback on #629). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 80fa258 commit f80e98f

14 files changed

Lines changed: 885 additions & 371 deletions

redisvl/mcp/config.py

Lines changed: 34 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,16 @@ class MCPSchemaOverrides(BaseModel):
282282

283283

284284
class MCPIndexBindingConfig(BaseModel):
285-
"""The sole configured v1 index binding."""
285+
"""A single configured logical index binding.
286+
287+
A server can configure one or many of these under ``indexes.<id>``. Each
288+
binding inspects and serves one existing Redis index independently, and
289+
owns its own schema inspection, runtime mapping, and search validation.
290+
"""
286291

287292
redis_name: str = Field(..., min_length=1)
293+
description: str | None = Field(default=None, min_length=1)
294+
read_only: bool = False
288295
vectorizer: MCPVectorizerConfig | None = None
289296
search: MCPIndexSearchConfig
290297
runtime: MCPRuntimeConfig
@@ -355,83 +362,9 @@ def _validate_capability_requirements(self) -> "MCPIndexBindingConfig":
355362

356363
return self
357364

358-
359-
class MCPConfig(BaseModel):
360-
"""Validated MCP server configuration loaded from YAML."""
361-
362-
server: MCPServerConfig
363-
indexes: dict[str, MCPIndexBindingConfig]
364-
365-
@model_validator(mode="after")
366-
def _validate_bindings(self) -> "MCPConfig":
367-
"""Validate that there is exactly one configured logical binding."""
368-
if len(self.indexes) != 1:
369-
raise ValueError(
370-
"indexes must contain exactly one configured index binding"
371-
)
372-
373-
binding_id = next(iter(self.indexes))
374-
if not binding_id.strip():
375-
raise ValueError("indexes binding id must be non-blank")
376-
return self
377-
378-
@property
379-
def binding_id(self) -> str:
380-
"""Return the single logical binding identifier configured for v1."""
381-
return next(iter(self.indexes))
382-
383-
@property
384-
def binding(self) -> MCPIndexBindingConfig:
385-
"""Return the sole configured binding."""
386-
return self.indexes[self.binding_id]
387-
388-
@property
389-
def runtime(self) -> MCPRuntimeConfig:
390-
"""Expose the sole binding's runtime config for phase 1."""
391-
return self.binding.runtime
392-
393-
@property
394-
def vectorizer(self) -> MCPVectorizerConfig | None:
395-
"""Expose the sole binding's vectorizer config for phase 1."""
396-
return self.binding.vectorizer
397-
398-
@property
399-
def search(self) -> MCPIndexSearchConfig:
400-
"""Expose the sole binding's configured search behavior."""
401-
return self.binding.search
402-
403-
@property
404-
def uses_text_search(self) -> bool:
405-
"""Return whether configured search uses a text field."""
406-
return self.binding.uses_text_search
407-
408-
@property
409-
def uses_query_embedding(self) -> bool:
410-
"""Return whether configured search embeds user queries."""
411-
return self.binding.uses_query_embedding
412-
413-
@property
414-
def supports_vector_backed_upsert(self) -> bool:
415-
"""Return whether configured upserts manage a vector field."""
416-
return self.binding.supports_vector_backed_upsert
417-
418-
@property
419-
def supports_server_side_embedding(self) -> bool:
420-
"""Return whether configured upserts can generate embeddings."""
421-
return self.binding.supports_server_side_embedding
422-
423-
@property
424-
def requires_startup_vectorizer(self) -> bool:
425-
"""Return whether startup must initialize a vectorizer."""
426-
return self.binding.requires_startup_vectorizer
427-
428-
@property
429-
def redis_name(self) -> str:
430-
"""Return the existing Redis index name that must be inspected at startup."""
431-
return self.binding.redis_name
432-
365+
@staticmethod
433366
def inspected_schema_from_index_info(
434-
self, index_info: dict[str, Any]
367+
index_info: dict[str, Any],
435368
) -> dict[str, Any]:
436369
"""Build a schema dict from FT.INFO while preserving discovered field identity.
437370
@@ -478,7 +411,7 @@ def merge_schema_overrides(
478411
if isinstance(field, dict) and "name" in field
479412
}
480413

481-
for override in self.binding.schema_overrides.fields:
414+
for override in self.schema_overrides.fields:
482415
discovered = discovered_fields.get(override.name)
483416
if discovered is None:
484417
raise ValueError(
@@ -575,6 +508,29 @@ def validate_search(
575508
)
576509

577510

511+
class MCPConfig(BaseModel):
512+
"""Validated MCP server configuration loaded from YAML.
513+
514+
``indexes`` is the canonical multi-binding map: a server may configure one
515+
or many logical bindings. Single-index configs remain valid and unchanged;
516+
each binding owns its own inspection, runtime mapping, and search behavior.
517+
"""
518+
519+
server: MCPServerConfig
520+
indexes: dict[str, MCPIndexBindingConfig]
521+
522+
@model_validator(mode="after")
523+
def _validate_bindings(self) -> "MCPConfig":
524+
"""Require at least one binding and reject blank logical ids."""
525+
if not self.indexes:
526+
raise ValueError("indexes must contain at least one configured binding")
527+
528+
for binding_id in self.indexes:
529+
if not binding_id.strip():
530+
raise ValueError("indexes binding id must be non-blank")
531+
return self
532+
533+
578534
def _substitute_env(value: Any) -> Any:
579535
"""Recursively resolve `${VAR}` and `${VAR:-default}` placeholders."""
580536
if isinstance(value, dict):

redisvl/mcp/runtime.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
4+
from redisvl.index import AsyncSearchIndex
5+
from redisvl.mcp.config import MCPIndexBindingConfig
6+
from redisvl.schema import IndexSchema
7+
8+
9+
@dataclass(frozen=True)
10+
class BindingRuntime:
11+
"""Immutable per-binding runtime state assembled once at server startup.
12+
13+
Each configured logical index becomes one ``BindingRuntime`` bundling the
14+
binding config with the resources a tool call needs: the connected index,
15+
its effective (inspected + overridden) schema, an optional vectorizer, the
16+
resolved native-hybrid-search capability, and the effective write policy.
17+
18+
Tools resolve a binding once via ``server.resolve_binding(index)`` and then
19+
read these attributes directly instead of calling back into the server.
20+
"""
21+
22+
binding_id: str
23+
binding: MCPIndexBindingConfig
24+
index: AsyncSearchIndex
25+
schema: IndexSchema
26+
vectorizer: Any | None
27+
supports_native_hybrid_search: bool
28+
effective_read_only: bool

0 commit comments

Comments
 (0)