Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ These tools follow a stable contract:

- request validation happens before query or write execution
- filters support either raw strings or a RedisVL-backed JSON DSL
- `search-records` describes the inspected schema by advertising typed JSON DSL filter fields, object-filter `exists` support, and valid `return_fields`
- error codes are mapped into a stable set of MCP-facing categories

## Why Use MCP Instead of Direct RedisVL Calls
Expand Down
3 changes: 2 additions & 1 deletion docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ You can also control boot settings through environment variables:
|----------|---------|
| `REDISVL_MCP_CONFIG` | Path to the MCP YAML config |
| `REDISVL_MCP_READ_ONLY` | Disable `upsert-records` when set to `true` |
| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | Override the search tool description |
| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | Set the base search tool description text; RedisVL still appends schema-derived typed filter, `exists`, and `return_fields` hints |
| `REDISVL_MCP_TOOL_UPSERT_DESCRIPTION` | Override the upsert tool description |

## Connect a Remote MCP Client
Expand Down Expand Up @@ -227,6 +227,7 @@ Notes:
- when `return_fields` is omitted, RedisVL MCP returns all non-vector fields
- returning the configured vector field is rejected
- `filter` accepts either a raw string or a JSON DSL object
- the `search-records` tool description includes schema-derived hints for typed JSON DSL filter fields, object-filter `exists` support, and valid `return_fields`
- `offset + limit` must stay within `runtime.max_result_window`
- startup rejects schemas that use MCP-reserved score metadata field names:
`id`, `__key`, `key`, `score`, `vector_distance`, `__score`, `text_score`, `vector_similarity`, `hybrid_score`
Expand Down
6 changes: 3 additions & 3 deletions redisvl/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,12 @@ async def supports_native_hybrid_search(self) -> bool:
)
return self._supports_native_hybrid_search

def _register_tools(self) -> None:
def _register_tools(self, schema: IndexSchema) -> None:
"""Register MCP tools once the server is ready."""
if self._tools_registered or not hasattr(self, "tool"):
return

register_search_tool(self)
register_search_tool(self, schema)
if not self.mcp_settings.read_only:
register_upsert_tool(self)
self._tools_registered = True
Expand Down Expand Up @@ -299,7 +299,7 @@ async def _initialize_runtime_resources(self) -> Any:
supports_native_hybrid_search=await self.supports_native_hybrid_search(),
)
await self._initialize_vectorizer(effective_schema, timeout)
self._register_tools()
self._register_tools(effective_schema)
return client
except Exception:
if self._index is None:
Expand Down
54 changes: 51 additions & 3 deletions redisvl/mcp/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception
from redisvl.mcp.filters import parse_filter
from redisvl.query import AggregateHybridQuery, HybridQuery, TextQuery, VectorQuery
from redisvl.schema import IndexSchema

DEFAULT_SEARCH_DESCRIPTION = "Search records in the configured Redis index."
_DSL_FILTER_FIELD_TYPES = frozenset({"tag", "text", "numeric"})

_NATIVE_HYBRID_DEFAULTS = {
"combination_method": "LINEAR",
Expand All @@ -25,6 +27,51 @@
)


def _build_filter_hint(schema: IndexSchema) -> str:
"""Describe fields with typed operator support in the JSON filter DSL."""
filter_fields = [
f"{field.name}({getattr(field.type, 'value', field.type)})"
for field in schema.fields.values()
if field.type in _DSL_FILTER_FIELD_TYPES
Comment thread
vishal-bala marked this conversation as resolved.
]
if not filter_fields:
return "Object filter fields: none."
return "Object filter fields: " + ", ".join(filter_fields) + "."


def _build_return_fields_hint(schema: IndexSchema) -> str:
"""Describe all fields that callers can request in `return_fields`."""
returnable_fields = [
field.name for field in schema.fields.values() if field.type != "vector"
]
if not returnable_fields:
return "Allowed return_fields: none."
return "Allowed return_fields: " + ", ".join(returnable_fields) + "."


def _build_search_tool_description(
schema: IndexSchema, base_description: str | None = None
) -> str:
"""Build the `search-records` description from static text plus schema hints."""
description = (base_description or DEFAULT_SEARCH_DESCRIPTION).strip()

# `exists` is currently accepted for any schema field in the MCP object filter.
exists_fields = [field.name for field in schema.fields.values()]
if exists_fields:
exists_hint = "Object filter exists support: " + ", ".join(exists_fields) + "."
else:
exists_hint = "Object filter exists support: none."

return " ".join(
[
description,
_build_filter_hint(schema),
exists_hint,
_build_return_fields_hint(schema),
]
)


def _validate_request(
*,
query: str,
Expand Down Expand Up @@ -405,10 +452,11 @@ async def search_records(
raise map_exception(exc) from exc


def register_search_tool(server: Any) -> None:
def register_search_tool(server: Any, schema: IndexSchema) -> None:
"""Register the MCP `search-records` tool with its config-owned contract."""
description = (
server.mcp_settings.tool_search_description or DEFAULT_SEARCH_DESCRIPTION
description = _build_search_tool_description(
schema=schema,
base_description=server.mcp_settings.tool_search_description,
)

async def search_records_tool(
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_mcp/test_upsert_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ async def test_read_only_mode_excludes_upsert_tool(
)
monkeypatch.setattr(
"redisvl.mcp.server.register_search_tool",
lambda server: None,
lambda server, schema: None,
)

def fake_tool(*args: Any, **kwargs: Any):
Expand Down
75 changes: 72 additions & 3 deletions tests/unit/test_mcp/test_search_tool_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError
from redisvl.mcp.tools.search import (
_build_fallback_hybrid_kwargs,
_build_search_tool_description,
_embed_query,
register_search_tool,
search_records,
Expand Down Expand Up @@ -664,17 +665,85 @@ async def test_search_records_rejects_native_only_hybrid_runtime_params(monkeypa

def test_register_search_tool_uses_default_and_override_descriptions():
default_server = FakeServer()
register_search_tool(default_server)
register_search_tool(default_server, default_server.index.schema)

assert default_server.registered_tools[0]["name"] == "search-records"
assert "Search records" in default_server.registered_tools[0]["description"]
assert (
"Object filter fields: content(text), category(tag), rating(numeric)."
in default_server.registered_tools[0]["description"]
)
assert (
"Object filter exists support: content, category, rating, embedding."
in default_server.registered_tools[0]["description"]
)
assert (
"Allowed return_fields: content, category, rating."
in default_server.registered_tools[0]["description"]
)
assert "query" in default_server.registered_tools[0]["fn"].__annotations__
assert "search_type" not in default_server.registered_tools[0]["fn"].__annotations__

custom_server = FakeServer()
custom_server.mcp_settings.tool_search_description = "Custom search description"
register_search_tool(custom_server)
register_search_tool(custom_server, custom_server.index.schema)

assert custom_server.registered_tools[0]["description"] == (
"Custom search description "
"Object filter fields: content(text), category(tag), rating(numeric). "
"Object filter exists support: content, category, rating, embedding. "
"Allowed return_fields: content, category, rating."
)


def test_build_search_tool_description_preserves_schema_order_and_excludes_vectors():
description = _build_search_tool_description(_schema())

assert (
"Object filter fields: content(text), category(tag), rating(numeric)."
in description
)
assert (
"Object filter exists support: content, category, rating, embedding."
in description
)
assert "Allowed return_fields: content, category, rating." in description
assert "embedding" not in description.split("Allowed return_fields: ", 1)[1]


def test_build_search_tool_description_distinguishes_typed_and_exists_support():
schema = IndexSchema.from_dict(
{
"index": {
"name": "docs-index",
"prefix": "doc",
"storage_type": "hash",
},
"fields": [
{"name": "content", "type": "text"},
{"name": "category", "type": "tag"},
{"name": "rating", "type": "numeric"},
{"name": "location", "type": "geo"},
{
"name": "embedding",
"type": "vector",
"attrs": {
"algorithm": "flat",
"dims": 3,
"distance_metric": "cosine",
"datatype": "float32",
},
},
],
}
)

description = _build_search_tool_description(schema)

assert "location(geo)" not in description
assert "embedding(vector)" not in description
assert (
custom_server.registered_tools[0]["description"] == "Custom search description"
"Object filter exists support: content, category, rating, location, embedding."
in description
)
assert "Allowed return_fields: content, category, rating, location." in description
92 changes: 92 additions & 0 deletions tests/unit/test_mcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,98 @@ async def fake_disconnect(self):
assert server._index is None


@pytest.mark.asyncio
async def test_server_registers_tools_with_effective_schema(monkeypatch):
monkeypatch.setattr(
"redisvl.mcp.server.FastMCP.__init__", lambda self, *a, **k: None
)
monkeypatch.setattr(
"redisvl.mcp.server.load_mcp_config",
lambda path: _startup_config(),
)

class FakeClient:
async def aclose(self):
return None

async def fake_connect(self, timeout):
return FakeClient()

async def fake_load_schema(self, client, timeout):
return IndexSchema.from_dict(
{
"index": {
"name": "docs-index",
"prefix": "doc",
"storage_type": "hash",
},
"fields": [
{"name": "content", "type": "text"},
{"name": "category", "type": "tag"},
{"name": "location", "type": "geo"},
{
"name": "embedding",
"type": "vector",
"attrs": {
"algorithm": "flat",
"dims": 3,
"distance_metric": "cosine",
"datatype": "float32",
},
},
],
}
)

async def fake_supports_native_hybrid_search(self):
return False

async def fake_initialize_vectorizer(self, schema, timeout):
self._vectorizer = SimpleNamespace(dims=3)

registered_schemas = []

def fake_register_search_tool(server, schema):
registered_schemas.append(schema)

async def fake_disconnect(self):
return None

monkeypatch.setattr(RedisVLMCPServer, "_connect_redis_client", fake_connect)
monkeypatch.setattr(RedisVLMCPServer, "_load_effective_schema", fake_load_schema)
monkeypatch.setattr(
RedisVLMCPServer,
"supports_native_hybrid_search",
fake_supports_native_hybrid_search,
)
monkeypatch.setattr(
RedisVLMCPServer, "_initialize_vectorizer", fake_initialize_vectorizer
)
monkeypatch.setattr(
"redisvl.mcp.server.register_search_tool", fake_register_search_tool
)
monkeypatch.setattr("redisvl.mcp.server.register_upsert_tool", lambda server: None)
monkeypatch.setattr(
"redisvl.mcp.server.AsyncSearchIndex.disconnect",
fake_disconnect,
raising=False,
)

server = RedisVLMCPServer(_dummy_settings())

await server.startup()

assert len(registered_schemas) == 1
assert list(registered_schemas[0].field_names) == [
"content",
"category",
"location",
"embedding",
]

await server.shutdown()


@pytest.mark.asyncio
async def test_startup_while_running_raises(monkeypatch):
monkeypatch.setattr(
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading