Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ dmypy.json
# Cython debug symbols
cython_debug/

# Codex
.codex/

# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
Expand Down
113 changes: 112 additions & 1 deletion redisvl/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, Literal, Optional

import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
Expand Down Expand Up @@ -71,6 +71,101 @@ class MCPServerConfig(BaseModel):
redis_url: str = Field(..., min_length=1)


class MCPIndexSearchConfig(BaseModel):
"""Configured search mode and query tuning for the bound index.

The MCP request contract only exposes query text, filtering, pagination, and
field projection. Search mode and query-tuning behavior are owned entirely by
YAML config and validated here.
"""

type: Literal["vector", "fulltext", "hybrid"]
params: Dict[str, Any] = Field(default_factory=dict)

@model_validator(mode="after")
def _validate_params(self) -> "MCPIndexSearchConfig":
"""Reject params that do not belong to the configured search mode."""
allowed_params = {
"vector": {
"hybrid_policy",
"batch_size",
"ef_runtime",
"epsilon",
"search_window_size",
"use_search_history",
"search_buffer_capacity",
"normalize_vector_distance",
},
"fulltext": {
"text_scorer",
"stopwords",
"text_weights",
},
"hybrid": {
"text_scorer",
"stopwords",
"text_weights",
"vector_search_method",
"knn_ef_runtime",
"range_radius",
"range_epsilon",
"combination_method",
"rrf_window",
"rrf_constant",
"linear_text_weight",
},
}
invalid_keys = sorted(set(self.params) - allowed_params[self.type])
if invalid_keys:
raise ValueError(
"search.params contains keys incompatible with "
f"search.type '{self.type}': {', '.join(invalid_keys)}"
)

if (
"linear_text_weight" in self.params
and self.params.get("combination_method") != "LINEAR"
):
raise ValueError(
"search.params.linear_text_weight requires combination_method to be LINEAR"
)
return self

def to_query_params(self) -> Dict[str, Any]:
"""Return normalized query kwargs exactly as configured."""
return dict(self.params)

def validate_runtime_capabilities(
self, *, supports_native_hybrid_search: bool
) -> None:
"""Fail startup when hybrid config depends on native-only FT.SEARCH params."""
if self.type != "hybrid" or supports_native_hybrid_search:
return

unsupported_params = set()
if self.params.get("combination_method") not in (None, "LINEAR"):
unsupported_params.add("combination_method")
unsupported_params.update(
key
for key in (
"vector_search_method",
"knn_ef_runtime",
"range_radius",
"range_epsilon",
"rrf_window",
"rrf_constant",
)
if key in self.params
)

if unsupported_params:
unsupported_list = ", ".join(sorted(unsupported_params))
raise ValueError(
"search.params requires native hybrid search support for: "
f"{unsupported_list}"
)


class MCPSchemaOverrideField(BaseModel):
"""Allowed schema override fragment for one already-discovered field."""

Expand All @@ -91,6 +186,7 @@ class MCPIndexBindingConfig(BaseModel):

redis_name: str = Field(..., min_length=1)
vectorizer: MCPVectorizerConfig
search: MCPIndexSearchConfig
runtime: MCPRuntimeConfig
schema_overrides: MCPSchemaOverrides = Field(default_factory=MCPSchemaOverrides)

Expand Down Expand Up @@ -134,6 +230,11 @@ def vectorizer(self) -> MCPVectorizerConfig:
"""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 redis_name(self) -> str:
"""Return the existing Redis index name that must be inspected at startup."""
Expand Down Expand Up @@ -255,6 +356,16 @@ def get_vector_field_dims(self, schema: IndexSchema) -> Optional[int]:
attrs = self.get_vector_field(schema).attrs
return getattr(attrs, "dims", None)

def validate_search(
self,
*,
supports_native_hybrid_search: bool,
) -> None:
"""Validate configured search behavior against current runtime support."""
self.search.validate_runtime_capabilities(
supports_native_hybrid_search=supports_native_hybrid_search
)


def _substitute_env(value: Any) -> Any:
"""Recursively resolve `${VAR}` and `${VAR:-default}` placeholders."""
Expand Down
1 change: 1 addition & 0 deletions redisvl/mcp/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class MCPErrorCode(str, Enum):
"""Stable internal error codes exposed by the MCP framework."""

INVALID_REQUEST = "invalid_request"
INVALID_FILTER = "invalid_filter"
DEPENDENCY_MISSING = "dependency_missing"
BACKEND_UNAVAILABLE = "backend_unavailable"
INTERNAL_ERROR = "internal_error"
Expand Down
Loading
Loading