Skip to content
Merged
112 changes: 34 additions & 78 deletions redisvl/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,16 @@ class MCPSchemaOverrides(BaseModel):


class MCPIndexBindingConfig(BaseModel):
"""The sole configured v1 index binding."""
"""A single configured logical index binding.

A server can configure one or many of these under ``indexes.<id>``. Each
binding inspects and serves one existing Redis index independently, and
owns its own schema inspection, runtime mapping, and search validation.
"""

redis_name: str = Field(..., min_length=1)
description: str | None = Field(default=None, min_length=1)
read_only: bool = False
vectorizer: MCPVectorizerConfig | None = None
search: MCPIndexSearchConfig
runtime: MCPRuntimeConfig
Expand Down Expand Up @@ -355,83 +362,9 @@ def _validate_capability_requirements(self) -> "MCPIndexBindingConfig":

return self


class MCPConfig(BaseModel):
"""Validated MCP server configuration loaded from YAML."""

server: MCPServerConfig
indexes: dict[str, MCPIndexBindingConfig]

@model_validator(mode="after")
def _validate_bindings(self) -> "MCPConfig":
"""Validate that there is exactly one configured logical binding."""
if len(self.indexes) != 1:
raise ValueError(
"indexes must contain exactly one configured index binding"
)

binding_id = next(iter(self.indexes))
if not binding_id.strip():
raise ValueError("indexes binding id must be non-blank")
return self

@property
def binding_id(self) -> str:
"""Return the single logical binding identifier configured for v1."""
return next(iter(self.indexes))

@property
def binding(self) -> MCPIndexBindingConfig:
"""Return the sole configured binding."""
return self.indexes[self.binding_id]

@property
def runtime(self) -> MCPRuntimeConfig:
"""Expose the sole binding's runtime config for phase 1."""
return self.binding.runtime

@property
def vectorizer(self) -> MCPVectorizerConfig | None:
"""Expose the sole binding's vectorizer config for phase 1."""
return self.binding.vectorizer

@property
def search(self) -> MCPIndexSearchConfig:
"""Expose the sole binding's configured search behavior."""
return self.binding.search

@property
def uses_text_search(self) -> bool:
"""Return whether configured search uses a text field."""
return self.binding.uses_text_search

@property
def uses_query_embedding(self) -> bool:
"""Return whether configured search embeds user queries."""
return self.binding.uses_query_embedding

@property
def supports_vector_backed_upsert(self) -> bool:
"""Return whether configured upserts manage a vector field."""
return self.binding.supports_vector_backed_upsert

@property
def supports_server_side_embedding(self) -> bool:
"""Return whether configured upserts can generate embeddings."""
return self.binding.supports_server_side_embedding

@property
def requires_startup_vectorizer(self) -> bool:
"""Return whether startup must initialize a vectorizer."""
return self.binding.requires_startup_vectorizer

@property
def redis_name(self) -> str:
"""Return the existing Redis index name that must be inspected at startup."""
return self.binding.redis_name

@staticmethod
def inspected_schema_from_index_info(
self, index_info: dict[str, Any]
index_info: dict[str, Any],
) -> dict[str, Any]:
"""Build a schema dict from FT.INFO while preserving discovered field identity.

Expand Down Expand Up @@ -478,7 +411,7 @@ def merge_schema_overrides(
if isinstance(field, dict) and "name" in field
}

for override in self.binding.schema_overrides.fields:
for override in self.schema_overrides.fields:
discovered = discovered_fields.get(override.name)
if discovered is None:
raise ValueError(
Expand Down Expand Up @@ -575,6 +508,29 @@ def validate_search(
)


class MCPConfig(BaseModel):
"""Validated MCP server configuration loaded from YAML.

``indexes`` is the canonical multi-binding map: a server may configure one
or many logical bindings. Single-index configs remain valid and unchanged;
each binding owns its own inspection, runtime mapping, and search behavior.
"""

server: MCPServerConfig
indexes: dict[str, MCPIndexBindingConfig]

@model_validator(mode="after")
def _validate_bindings(self) -> "MCPConfig":
"""Require at least one binding and reject blank logical ids."""
if not self.indexes:
raise ValueError("indexes must contain at least one configured binding")

for binding_id in self.indexes:
if not binding_id.strip():
raise ValueError("indexes binding id must be non-blank")
return self


def _substitute_env(value: Any) -> Any:
"""Recursively resolve `${VAR}` and `${VAR:-default}` placeholders."""
if isinstance(value, dict):
Expand Down
28 changes: 28 additions & 0 deletions redisvl/mcp/runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from dataclasses import dataclass
from typing import Any

from redisvl.index import AsyncSearchIndex
from redisvl.mcp.config import MCPIndexBindingConfig
from redisvl.schema import IndexSchema


@dataclass(frozen=True)
class BindingRuntime:
"""Immutable per-binding runtime state assembled once at server startup.

Each configured logical index becomes one ``BindingRuntime`` bundling the
binding config with the resources a tool call needs: the connected index,
its effective (inspected + overridden) schema, an optional vectorizer, the
resolved native-hybrid-search capability, and the effective write policy.

Tools resolve a binding once via ``server.resolve_binding(index)`` and then
read these attributes directly instead of calling back into the server.
"""

binding_id: str
binding: MCPIndexBindingConfig
index: AsyncSearchIndex
schema: IndexSchema
vectorizer: Any | None
supports_native_hybrid_search: bool
effective_read_only: bool
Loading
Loading