Skip to content

Commit df4d64c

Browse files
authored
fix(MCP): Avoid required search config fields that aren't relevant to the configured search type (#591)
## Motivation The RedisVL MCP config validation was treating vector and text settings as universally required, even when the configured search mode did not use them. In practice, that meant a full-text-only deployment still had to provide vector-specific settings such as a vectorizer, a vector field name, and embed-source configuration just to start the server. That made the MCP surface unnecessarily rigid and blocked valid non-vector use cases. The pain point was most visible in two places. First, `search.type: vector` was incorrectly forcing `runtime.text_field_name`, even though vector search does not use a text field for retrieval. Second, `search.type: fulltext` still inherited vector-only startup and config requirements because the server always initialized a vectorizer and always validated vector mappings. The result was that irrelevant settings became mandatory, which made configs harder to reason about and made full-text-only deployments look unsupported even though the runtime behavior itself did not need those dependencies. ## Changes This change makes RedisVL MCP validation capability-aware instead of globally strict. The YAML shape stays the same, but runtime settings and vectorizer requirements are now enforced only when the configured behavior actually needs them. `runtime.text_field_name` is required for `fulltext` and `hybrid` search, while `runtime.vector_field_name` and `vectorizer` are required only for vector-backed search or server-side embedding. `runtime.default_embed_text_field` is now treated as an embedding capability setting rather than a universal requirement. Startup behavior was updated to match that model. The server no longer initializes and dimension-checks a vectorizer for deployments that do not need one. That allows a full-text-only MCP config to start cleanly without vector settings, while keeping the existing fail-fast behavior for vector and hybrid search. The write path was also split into capability-specific branches. `upsert-records` now supports plain schema-validated writes when no vector field is configured. When a vector field is configured but server-side embedding is not, the caller must provide vectors explicitly and the request fails at upsert time if required vectors are missing. When server-side embedding is configured, the previous embedding flow remains in place. As a small follow-up, the patch also adds a TODO near the embedding call noting that the current implementation can still re-embed records that already include vectors, which is wasteful and may add external embedding cost. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches MCP config validation and request-time behavior for search/upsert, which can change startup acceptance and error paths for existing deployments, especially around optional vector/embedding settings. > > **Overview** > RedisVL MCP now treats vector-related settings as *optional capabilities* rather than universally required, allowing fulltext-only deployments to omit `vectorizer`, `runtime.vector_field_name`, and `runtime.default_embed_text_field` while still enforcing the right requirements for `vector` and `hybrid` search. > > Config validation was updated to be search-mode aware (new capability helpers and validators), server startup only initializes a vectorizer when required, `search-records` and `upsert-records` add explicit runtime checks for missing text/vector fields, and upsert now supports plain writes with no vector config while requiring caller-supplied vectors when a vector field is configured but server-side embedding is not. Docs and tests were expanded to cover fulltext-only configs and the new upsert behaviors. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 889c5e2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 7fc9d87 commit df4d64c

13 files changed

Lines changed: 822 additions & 148 deletions

File tree

docs/concepts/mcp.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The RedisVL MCP server sits between an MCP client and Redis:
1515

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

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

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

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

9090
These tools follow a stable contract:
9191

docs/user_guide/how_to_guides/mcp.md

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,43 @@ indexes:
161161
162162
- `redis_name` must point to an index that already exists in Redis
163163
- `search.type` fixes retrieval behavior for every MCP caller
164-
- `runtime.text_field_name` tells full-text and hybrid search which field to search
165-
- `runtime.vector_field_name` tells the server which vector field to use
166-
- `runtime.default_embed_text_field` tells upsert which text field to embed when a record needs embedding
164+
- `runtime.text_field_name` is required for `fulltext` and `hybrid` search
165+
- `runtime.vector_field_name` is required for `vector` and `hybrid` search, and optional for plain full-text deployments
166+
- `runtime.default_embed_text_field` is only required when the server should generate embeddings during upsert
167+
- `vectorizer` is required for query embedding and server-side embedding, but optional for fulltext-only configs
167168
- `runtime.max_result_window` caps deep paging by limiting the maximum `offset + limit`
168169
- `schema_overrides` is only for patching incomplete field attrs discovered from Redis
169170

171+
### Fulltext-Only Config
172+
173+
For a non-vector deployment, omit vector-only settings entirely:
174+
175+
```yaml
176+
server:
177+
redis_url: ${REDIS_URL}
178+
179+
indexes:
180+
knowledge:
181+
redis_name: knowledge
182+
183+
search:
184+
type: fulltext
185+
params:
186+
text_scorer: BM25STD
187+
stopwords: english
188+
189+
runtime:
190+
text_field_name: content
191+
default_limit: 10
192+
max_limit: 25
193+
max_result_window: 1000
194+
max_upsert_records: 64
195+
skip_embedding_if_present: true
196+
startup_timeout_seconds: 30
197+
request_timeout_seconds: 60
198+
max_concurrency: 16
199+
```
200+
170201
## Tool Contracts
171202

172203
RedisVL MCP exposes a small, implementation-owned contract.
@@ -269,8 +300,9 @@ Example response payload:
269300
Notes:
270301

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

275307
## Search Examples
276308

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

426458
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`.
427459

460+
### Plain Writes Without Embedding
461+
462+
For fulltext-only indexes, `upsert-records` can write records without any vectorizer or vector field configuration:
463+
464+
```json
465+
{
466+
"records": [
467+
{
468+
"content": "Updated FAQ entry",
469+
"category": "support",
470+
"rating": 5
471+
}
472+
]
473+
}
474+
```
475+
476+
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.
477+
428478
## Troubleshooting
429479

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

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

440490
### Configured Redis Index Does Not Exist
441491

redisvl/mcp/config.py

Lines changed: 115 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ def reserved_score_metadata_field_names() -> frozenset[str]:
3434
class MCPRuntimeConfig(BaseModel):
3535
"""Runtime limits and validated field mappings for MCP requests."""
3636

37-
text_field_name: str = Field(..., min_length=1)
38-
vector_field_name: str = Field(..., min_length=1)
39-
default_embed_text_field: str = Field(..., min_length=1)
37+
text_field_name: str | None = Field(default=None, min_length=1)
38+
vector_field_name: str | None = Field(default=None, min_length=1)
39+
default_embed_text_field: str | None = Field(default=None, min_length=1)
4040
default_limit: int = 10
4141
max_limit: int = 100
4242
max_result_window: int = 1000
@@ -209,11 +209,76 @@ class MCPIndexBindingConfig(BaseModel):
209209
"""The sole configured v1 index binding."""
210210

211211
redis_name: str = Field(..., min_length=1)
212-
vectorizer: MCPVectorizerConfig
212+
vectorizer: MCPVectorizerConfig | None = None
213213
search: MCPIndexSearchConfig
214214
runtime: MCPRuntimeConfig
215215
schema_overrides: MCPSchemaOverrides = Field(default_factory=MCPSchemaOverrides)
216216

217+
@property
218+
def uses_text_search(self) -> bool:
219+
"""Return whether search queries depend on a configured text field."""
220+
return self.search.type in {"fulltext", "hybrid"}
221+
222+
@property
223+
def uses_query_embedding(self) -> bool:
224+
"""Return whether search queries require embedding the user's query."""
225+
return self.search.type in {"vector", "hybrid"}
226+
227+
@property
228+
def supports_vector_backed_upsert(self) -> bool:
229+
"""Return whether upsert should manage a configured vector field."""
230+
return self.runtime.vector_field_name is not None
231+
232+
@property
233+
def supports_server_side_embedding(self) -> bool:
234+
"""Return whether upsert can generate vectors from text fields."""
235+
return (
236+
self.runtime.vector_field_name is not None
237+
and self.runtime.default_embed_text_field is not None
238+
and self.vectorizer is not None
239+
)
240+
241+
@property
242+
def requires_startup_vectorizer(self) -> bool:
243+
"""Return whether startup must initialize a configured vectorizer."""
244+
return self.uses_query_embedding or self.supports_server_side_embedding
245+
246+
@model_validator(mode="after")
247+
def _validate_capability_requirements(self) -> "MCPIndexBindingConfig":
248+
"""Require only the config fields needed by enabled capabilities."""
249+
if self.uses_text_search and self.runtime.text_field_name is None:
250+
raise ValueError(
251+
"runtime.text_field_name is required for "
252+
f"search.type '{self.search.type}'"
253+
)
254+
255+
if self.uses_query_embedding and self.runtime.vector_field_name is None:
256+
raise ValueError(
257+
"runtime.vector_field_name is required for "
258+
f"search.type '{self.search.type}'"
259+
)
260+
261+
if self.uses_query_embedding and self.vectorizer is None:
262+
raise ValueError(
263+
f"vectorizer is required for search.type '{self.search.type}'"
264+
)
265+
266+
if (
267+
self.runtime.default_embed_text_field is not None
268+
and self.runtime.vector_field_name is None
269+
):
270+
raise ValueError(
271+
"runtime.default_embed_text_field requires runtime.vector_field_name"
272+
)
273+
274+
if (
275+
self.runtime.default_embed_text_field is not None
276+
and self.vectorizer is None
277+
):
278+
raise ValueError("runtime.default_embed_text_field requires vectorizer")
279+
280+
return self
281+
217282

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

252317
@property
253-
def vectorizer(self) -> MCPVectorizerConfig:
318+
def vectorizer(self) -> MCPVectorizerConfig | None:
254319
"""Expose the sole binding's vectorizer config for phase 1."""
255320
return self.binding.vectorizer
256321

@@ -259,6 +324,31 @@ def search(self) -> MCPIndexSearchConfig:
259324
"""Expose the sole binding's configured search behavior."""
260325
return self.binding.search
261326

327+
@property
328+
def uses_text_search(self) -> bool:
329+
"""Return whether configured search uses a text field."""
330+
return self.binding.uses_text_search
331+
332+
@property
333+
def uses_query_embedding(self) -> bool:
334+
"""Return whether configured search embeds user queries."""
335+
return self.binding.uses_query_embedding
336+
337+
@property
338+
def supports_vector_backed_upsert(self) -> bool:
339+
"""Return whether configured upserts manage a vector field."""
340+
return self.binding.supports_vector_backed_upsert
341+
342+
@property
343+
def supports_server_side_embedding(self) -> bool:
344+
"""Return whether configured upserts can generate embeddings."""
345+
return self.binding.supports_server_side_embedding
346+
347+
@property
348+
def requires_startup_vectorizer(self) -> bool:
349+
"""Return whether startup must initialize a vectorizer."""
350+
return self.binding.requires_startup_vectorizer
351+
262352
@property
263353
def redis_name(self) -> str:
264354
"""Return the existing Redis index name that must be inspected at startup."""
@@ -343,26 +433,33 @@ def validate_runtime_mapping(self, schema: IndexSchema) -> None:
343433
"""Ensure runtime mappings point at explicit fields in the effective schema."""
344434
field_names = set(schema.field_names)
345435

346-
if self.runtime.text_field_name not in field_names:
436+
if self.uses_text_search and self.runtime.text_field_name not in field_names:
347437
raise ValueError(
348438
f"runtime.text_field_name '{self.runtime.text_field_name}' not found in schema"
349439
)
350440

351-
if self.runtime.default_embed_text_field not in field_names:
441+
if (
442+
self.supports_server_side_embedding
443+
and self.runtime.default_embed_text_field not in field_names
444+
):
352445
raise ValueError(
353446
"runtime.default_embed_text_field "
354447
f"'{self.runtime.default_embed_text_field}' not found in schema"
355448
)
356449

357-
vector_field = schema.fields.get(self.runtime.vector_field_name)
358-
if vector_field is None:
359-
raise ValueError(
360-
f"runtime.vector_field_name '{self.runtime.vector_field_name}' not found in schema"
361-
)
362-
if vector_field.type != "vector":
363-
raise ValueError(
364-
f"runtime.vector_field_name '{self.runtime.vector_field_name}' must reference a vector field"
365-
)
450+
if self.uses_query_embedding or self.supports_vector_backed_upsert:
451+
vector_field_name = self.runtime.vector_field_name
452+
if vector_field_name is None:
453+
raise ValueError("runtime.vector_field_name is not configured")
454+
vector_field = schema.fields.get(vector_field_name)
455+
if vector_field is None:
456+
raise ValueError(
457+
f"runtime.vector_field_name '{vector_field_name}' not found in schema"
458+
)
459+
if vector_field.type != "vector":
460+
raise ValueError(
461+
f"runtime.vector_field_name '{vector_field_name}' must reference a vector field"
462+
)
366463

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

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

378477
def get_vector_field_dims(self, schema: IndexSchema) -> int | None:

redisvl/mcp/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,10 @@ async def get_index(self) -> AsyncSearchIndex:
106106

107107
async def get_vectorizer(self) -> Any:
108108
"""Return the initialized vectorizer or fail if startup has not run."""
109-
if self._vectorizer is None:
109+
if self.config is None:
110110
raise RuntimeError("MCP server has not been started")
111+
if self._vectorizer is None:
112+
raise RuntimeError("MCP server vectorizer is not configured")
111113
return self._vectorizer
112114

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

151155
vectorizer_class = resolve_vectorizer_class(self.config.vectorizer.class_name)
152156
return vectorizer_class(**self.config.vectorizer.to_init_kwargs())
@@ -298,7 +302,8 @@ async def _initialize_runtime_resources(self) -> Any:
298302
schema=effective_schema,
299303
supports_native_hybrid_search=await self.supports_native_hybrid_search(),
300304
)
301-
await self._initialize_vectorizer(effective_schema, timeout)
305+
if self.config.requires_startup_vectorizer:
306+
await self._initialize_vectorizer(effective_schema, timeout)
302307
self._register_tools(effective_schema)
303308
return client
304309
except Exception:

0 commit comments

Comments
 (0)