Skip to content
Open
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
6 changes: 3 additions & 3 deletions docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The RedisVL MCP server sits between an MCP client and Redis:

1. It connects to an existing Redis Search index.
2. It inspects that index at startup and reconstructs its schema.
3. It instantiates the configured vectorizer for query embedding and optional upsert embedding.
3. It initializes vector capabilities only when the configured search or upsert behavior needs them.
4. It exposes stable MCP tools for search, and optionally upsert.

This keeps the Redis index as the source of truth for search behavior while giving MCP clients a predictable interface.
Expand All @@ -27,7 +27,7 @@ RedisVL MCP works with a focused model:
- One server process binds to exactly one existing Redis index.
- The server supports stdio (default), Streamable HTTP, and SSE transports.
- Search behavior is owned by configuration, not by MCP callers.
- The vectorizer is configured explicitly.
- Vector search and server-side embedding are optional capabilities configured explicitly.
- Upsert is optional and can be disabled with read-only mode.

## Config-Owned Search Behavior
Expand Down Expand Up @@ -85,7 +85,7 @@ Use read-only mode when Redis is serving approved content to assistants and anot
RedisVL MCP exposes two tools:

- `search-records` searches the configured index using the server-owned search mode
- `upsert-records` validates and upserts records, embedding them when needed
- `upsert-records` validates and upserts records, embedding them only when that capability is configured

These tools follow a stable contract:

Expand Down
62 changes: 56 additions & 6 deletions docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,43 @@ indexes:

- `redis_name` must point to an index that already exists in Redis
- `search.type` fixes retrieval behavior for every MCP caller
- `runtime.text_field_name` tells full-text and hybrid search which field to search
- `runtime.vector_field_name` tells the server which vector field to use
- `runtime.default_embed_text_field` tells upsert which text field to embed when a record needs embedding
- `runtime.text_field_name` is required for `fulltext` and `hybrid` search
- `runtime.vector_field_name` is required for `vector` and `hybrid` search, and optional for plain full-text deployments
- `runtime.default_embed_text_field` is only required when the server should generate embeddings during upsert
- `vectorizer` is required for query embedding and server-side embedding, but optional for fulltext-only configs
- `runtime.max_result_window` caps deep paging by limiting the maximum `offset + limit`
- `schema_overrides` is only for patching incomplete field attrs discovered from Redis

### Fulltext-Only Config

For a non-vector deployment, omit vector-only settings entirely:

```yaml
server:
redis_url: ${REDIS_URL}

indexes:
knowledge:
redis_name: knowledge

search:
type: fulltext
params:
text_scorer: BM25STD
stopwords: english

runtime:
text_field_name: content
default_limit: 10
max_limit: 25
max_result_window: 1000
max_upsert_records: 64
skip_embedding_if_present: true
startup_timeout_seconds: 30
request_timeout_seconds: 60
max_concurrency: 16
```

## Tool Contracts

RedisVL MCP exposes a small, implementation-owned contract.
Expand Down Expand Up @@ -269,8 +300,9 @@ Example response payload:
Notes:

- this tool is not registered in read-only mode
- records that need embedding must contain `runtime.default_embed_text_field`
- when `skip_embedding_if_present` is `true`, records that already contain the vector field can skip re-embedding
- when server-side embedding is configured, records that need embedding must contain `runtime.default_embed_text_field`
- when `skip_embedding_if_present` is `true`, records that already contain the configured vector field can skip re-embedding
- when a vector field is configured but server-side embedding is disabled, callers must supply vectors explicitly

## Search Examples

Expand Down Expand Up @@ -425,6 +457,24 @@ If a record does not include the configured vector field, RedisVL MCP embeds `ru

Set `skip_embedding_if_present` to `false` when you want the server to regenerate embeddings during upsert. In most cases, the caller should omit the vector field and let the server manage embeddings from `runtime.default_embed_text_field`.

### Plain Writes Without Embedding

For fulltext-only indexes, `upsert-records` can write records without any vectorizer or vector field configuration:

```json
{
"records": [
{
"content": "Updated FAQ entry",
"category": "support",
"rating": 5
}
]
}
```

If you configure a vector field but omit server-side embedding support, the caller must send vectors in each record instead of relying on the server to generate them.

## Troubleshooting

### Missing MCP Dependencies
Expand All @@ -435,7 +485,7 @@ If `rvl mcp` reports missing optional dependencies, install the MCP extra:
pip install redisvl[mcp]
```

If the configured vectorizer needs a provider SDK, install that provider extra too.
If the configured vectorizer needs a provider SDK, install that provider extra too. Fulltext-only configs can omit the vectorizer entirely.

### Configured Redis Index Does Not Exist

Expand Down
131 changes: 115 additions & 16 deletions redisvl/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ def reserved_score_metadata_field_names() -> frozenset[str]:
class MCPRuntimeConfig(BaseModel):
"""Runtime limits and validated field mappings for MCP requests."""

text_field_name: str = Field(..., min_length=1)
vector_field_name: str = Field(..., min_length=1)
default_embed_text_field: str = Field(..., min_length=1)
text_field_name: str | None = Field(default=None, min_length=1)
vector_field_name: str | None = Field(default=None, min_length=1)
default_embed_text_field: str | None = Field(default=None, min_length=1)
default_limit: int = 10
max_limit: int = 100
max_result_window: int = 1000
Expand Down Expand Up @@ -209,11 +209,76 @@ class MCPIndexBindingConfig(BaseModel):
"""The sole configured v1 index binding."""

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

@property
def uses_text_search(self) -> bool:
"""Return whether search queries depend on a configured text field."""
return self.search.type in {"fulltext", "hybrid"}

@property
def uses_query_embedding(self) -> bool:
"""Return whether search queries require embedding the user's query."""
return self.search.type in {"vector", "hybrid"}

@property
def supports_vector_backed_upsert(self) -> bool:
"""Return whether upsert should manage a configured vector field."""
return self.runtime.vector_field_name is not None

@property
def supports_server_side_embedding(self) -> bool:
"""Return whether upsert can generate vectors from text fields."""
return (
self.runtime.vector_field_name is not None
and self.runtime.default_embed_text_field is not None
and self.vectorizer is not None
)

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

@model_validator(mode="after")
def _validate_capability_requirements(self) -> "MCPIndexBindingConfig":
"""Require only the config fields needed by enabled capabilities."""
if self.uses_text_search and self.runtime.text_field_name is None:
raise ValueError(
"runtime.text_field_name is required for "
f"search.type '{self.search.type}'"
)

if self.uses_query_embedding and self.runtime.vector_field_name is None:
raise ValueError(
"runtime.vector_field_name is required for "
f"search.type '{self.search.type}'"
)

if self.uses_query_embedding and self.vectorizer is None:
raise ValueError(
f"vectorizer is required for search.type '{self.search.type}'"
)

if (
self.runtime.default_embed_text_field is not None
and self.runtime.vector_field_name is None
):
raise ValueError(
"runtime.default_embed_text_field requires runtime.vector_field_name"
)

if (
self.runtime.default_embed_text_field is not None
and self.vectorizer is None
):
raise ValueError("runtime.default_embed_text_field requires vectorizer")

return self


class MCPConfig(BaseModel):
"""Validated MCP server configuration loaded from YAML."""
Expand Down Expand Up @@ -250,7 +315,7 @@ def runtime(self) -> MCPRuntimeConfig:
return self.binding.runtime

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

Expand All @@ -259,6 +324,31 @@ 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."""
Expand Down Expand Up @@ -343,26 +433,33 @@ def validate_runtime_mapping(self, schema: IndexSchema) -> None:
"""Ensure runtime mappings point at explicit fields in the effective schema."""
field_names = set(schema.field_names)

if self.runtime.text_field_name not in field_names:
if self.uses_text_search and self.runtime.text_field_name not in field_names:
raise ValueError(
f"runtime.text_field_name '{self.runtime.text_field_name}' not found in schema"
)

if self.runtime.default_embed_text_field not in field_names:
if (
self.supports_server_side_embedding
and self.runtime.default_embed_text_field not in field_names
):
raise ValueError(
"runtime.default_embed_text_field "
f"'{self.runtime.default_embed_text_field}' not found in schema"
)

vector_field = schema.fields.get(self.runtime.vector_field_name)
if vector_field is None:
raise ValueError(
f"runtime.vector_field_name '{self.runtime.vector_field_name}' not found in schema"
)
if vector_field.type != "vector":
raise ValueError(
f"runtime.vector_field_name '{self.runtime.vector_field_name}' must reference a vector field"
)
if self.uses_query_embedding or self.supports_vector_backed_upsert:
vector_field_name = self.runtime.vector_field_name
if vector_field_name is None:
raise ValueError("runtime.vector_field_name is not configured")
vector_field = schema.fields.get(vector_field_name)
if vector_field is None:
raise ValueError(
f"runtime.vector_field_name '{vector_field_name}' not found in schema"
)
if vector_field.type != "vector":
raise ValueError(
f"runtime.vector_field_name '{vector_field_name}' must reference a vector field"
)

def to_index_schema(self, inspected_schema: dict[str, Any]) -> IndexSchema:
"""Apply overrides to an inspected schema and validate the effective result."""
Expand All @@ -373,6 +470,8 @@ def to_index_schema(self, inspected_schema: dict[str, Any]) -> IndexSchema:

def get_vector_field(self, schema: IndexSchema) -> BaseField:
"""Return the effective vector field from a validated schema."""
if self.runtime.vector_field_name is None:
raise ValueError("runtime.vector_field_name is not configured")
return schema.fields[self.runtime.vector_field_name]

def get_vector_field_dims(self, schema: IndexSchema) -> int | None:
Expand Down
9 changes: 7 additions & 2 deletions redisvl/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ async def get_index(self) -> AsyncSearchIndex:

async def get_vectorizer(self) -> Any:
"""Return the initialized vectorizer or fail if startup has not run."""
if self._vectorizer is None:
if self.config is None:
raise RuntimeError("MCP server has not been started")
if self._vectorizer is None:
raise RuntimeError("MCP server vectorizer is not configured")
return self._vectorizer

async def run_guarded(self, operation_name: str, awaitable: Awaitable[Any]) -> Any:
Expand Down Expand Up @@ -147,6 +149,8 @@ def _build_vectorizer(self) -> Any:
"""Instantiate the configured vectorizer class from validated config."""
if self.config is None:
raise RuntimeError("MCP server config not loaded")
if self.config.vectorizer is None:
raise RuntimeError("MCP server vectorizer is not configured")

vectorizer_class = resolve_vectorizer_class(self.config.vectorizer.class_name)
return vectorizer_class(**self.config.vectorizer.to_init_kwargs())
Expand Down Expand Up @@ -298,7 +302,8 @@ async def _initialize_runtime_resources(self) -> Any:
schema=effective_schema,
supports_native_hybrid_search=await self.supports_native_hybrid_search(),
)
await self._initialize_vectorizer(effective_schema, timeout)
if self.config.requires_startup_vectorizer:
await self._initialize_vectorizer(effective_schema, timeout)
self._register_tools(effective_schema)
return client
except Exception:
Expand Down
Loading
Loading