Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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