From d7beba20de4c679178b6caa2faeeacb47ad4f534 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 3 Mar 2026 17:27:20 +0100 Subject: [PATCH 1/5] Add MCP spec for review --- spec/MCP.md | 776 ++++++++++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 2 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 spec/MCP.md diff --git a/spec/MCP.md b/spec/MCP.md new file mode 100644 index 00000000..4f26e6da --- /dev/null +++ b/spec/MCP.md @@ -0,0 +1,776 @@ +# RedisVL MCP Server Specification + +## Overview + +This specification defines the implementation of a Model Context Protocol (MCP) server for RedisVL. The MCP server enables AI agents and LLM applications to interact with Redis as a vector database through a standardized protocol. + +### Goals + +1. Expose RedisVL's vector search capabilities to MCP-compatible clients (Claude Desktop, Claude Agents SDK, etc.) +2. Provide tools for semantic search, full-text search, hybrid search, and data upsert +3. Integrate seamlessly with the existing RedisVL architecture +4. Support the `uvx --from redisvl rvl mcp` pattern for easy deployment + +### References + +- [Model Context Protocol Specification](https://modelcontextprotocol.io/) +- [FastMCP Library](https://github.com/jlowin/fastmcp) +- [Qdrant MCP Server](https://github.com/qdrant/mcp-server-qdrant) - Similar scope reference +- [Redis Agent Memory Server](https://github.com/redis-developer/agent-memory-server) - Implementation patterns + +--- + +## Architecture + +### Module Structure + +``` +redisvl/ +├── mcp/ +│ ├── __init__.py # Public exports +│ ├── server.py # RedisVLMCPServer class (extends FastMCP) +│ ├── settings.py # MCPSettings (pydantic-settings) +│ ├── tools/ +│ │ ├── __init__.py +│ │ ├── search.py # Search tool implementation +│ │ └── upsert.py # Upsert tool implementation +│ └── utils.py # Helper functions +├── cli/ +│ └── ... (existing) # Add `mcp` subcommand +``` + +### Dependencies + +The MCP functionality is an **optional dependency group**: + +```toml +# pyproject.toml +[project.optional-dependencies] +mcp = [ + "mcp>=1.9.0", # MCP SDK with FastMCP +] +``` + +Installation: `pip install redisvl[mcp]` + +### Core Components + +1. **RedisVLMCPServer**: Main server class extending `FastMCP` +2. **MCPSettings**: Configuration via environment variables (pydantic-settings) +3. **Tool implementations**: Search and upsert operations +4. **CLI integration**: `rvl mcp` subcommand + +--- + +## Configuration (MCPSettings) + +Settings are configured via environment variables, following the pattern established by Qdrant MCP and Agent Memory Server. + +### Environment Variables + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `REDISVL_MCP_CONFIG` | str | (required) | Path to MCP configuration YAML file | +| `REDISVL_MCP_READ_ONLY` | bool | `false` | Disable upsert tool when true | +| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | str | (see below) | Custom search tool description | +| `REDISVL_MCP_TOOL_UPSERT_DESCRIPTION` | str | (see below) | Custom upsert tool description | + +### MCP Configuration File + +All MCP server configuration is consolidated into a **single YAML file** that includes Redis connection, index schema, and vectorizer settings. This simplifies deployment and keeps related configuration together. + +#### Configuration File Format + +```yaml +# mcp_config.yaml + +# Redis connection +redis_url: redis://localhost:6379 + +# Index schema (inline, same format as existing RedisVL schemas) +index: + name: my_index + prefix: doc + storage_type: hash # or "json" + +fields: + - name: content + type: text + - name: category + type: tag + - name: embedding + type: vector + attrs: + algorithm: hnsw + dims: 1536 + distance_metric: cosine + datatype: float32 + +# Vectorizer configuration +# Use the exact class name from redisvl.utils.vectorize +vectorizer: + class: OpenAITextVectorizer # Required: vectorizer class name + model: text-embedding-3-small # Required: model name + + # Additional kwargs passed directly to the vectorizer constructor + # Most providers use environment variables by default for API keys +``` + +#### Provider-Specific Vectorizer Examples + +```yaml +# OpenAI (simplest - uses OPENAI_API_KEY env var automatically) +vectorizer: + class: OpenAITextVectorizer + model: text-embedding-3-small + +# Azure OpenAI +vectorizer: + class: AzureOpenAITextVectorizer + model: text-embedding-ada-002 + api_key: ${AZURE_OPENAI_API_KEY} + api_version: "2024-02-01" + azure_endpoint: ${AZURE_OPENAI_ENDPOINT} + +# AWS Bedrock +vectorizer: + class: BedrockTextVectorizer + model: amazon.titan-embed-text-v1 + region_name: us-east-1 + # Uses AWS credentials from environment/IAM role by default + +# Google VertexAI +vectorizer: + class: VertexAITextVectorizer + model: textembedding-gecko@003 + project_id: ${GCP_PROJECT_ID} + location: us-central1 + +# HuggingFace (local embeddings) +vectorizer: + class: HFTextVectorizer + model: sentence-transformers/all-MiniLM-L6-v2 + +# Cohere +vectorizer: + class: CohereTextVectorizer + model: embed-english-v3.0 + # Uses COHERE_API_KEY env var automatically + +# Mistral +vectorizer: + class: MistralAITextVectorizer + model: mistral-embed + # Uses MISTRAL_API_KEY env var automatically + +# VoyageAI +vectorizer: + class: VoyageAITextVectorizer + model: voyage-2 + # Uses VOYAGE_API_KEY env var automatically +``` + +### Configuration Loader + +```python +# redisvl/mcp/config.py +from typing import Any, Dict, Optional +import os +import re +import yaml + +from redisvl.schema import IndexSchema + +def load_mcp_config(config_path: str) -> Dict[str, Any]: + """Load MCP config with environment variable substitution.""" + with open(config_path) as f: + content = f.read() + + # Substitute ${VAR} patterns with environment variables + def replace_env(match): + var_name = match.group(1) + return os.environ.get(var_name, "") + + content = re.sub(r'\$\{(\w+)\}', replace_env, content) + return yaml.safe_load(content) + +def create_index_schema(config: Dict[str, Any]) -> IndexSchema: + """Create IndexSchema from the index/fields portion of config.""" + schema_dict = { + "index": config["index"], + "fields": config["fields"], + } + return IndexSchema.from_dict(schema_dict) + +def create_vectorizer(config: Dict[str, Any]): + """Create vectorizer instance from config using class name. + + The vectorizer config should have: + - class: The exact class name (e.g., "OpenAITextVectorizer") + - model: The model name + - Any additional kwargs are passed to the constructor + """ + vec_config = config.get("vectorizer", {}).copy() + + class_name = vec_config.pop("class", None) + if not class_name: + raise ValueError("Vectorizer 'class' is required in configuration") + + # Import the vectorizer class dynamically + import redisvl.utils.vectorize as vectorize_module + + if not hasattr(vectorize_module, class_name): + raise ValueError( + f"Unknown vectorizer class: {class_name}. " + f"Must be a class from redisvl.utils.vectorize" + ) + + vectorizer_class = getattr(vectorize_module, class_name) + + # All remaining config keys are passed as kwargs to the constructor + return vectorizer_class(**vec_config) +``` + +### Settings Class + +```python +# redisvl/mcp/settings.py +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Optional + +class MCPSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="REDISVL_MCP_", + env_file=".env", + extra="ignore", + ) + + # Path to unified MCP configuration file + config: str # Required: path to mcp_config.yaml + + # Server mode (can also be set in config file, env var takes precedence) + read_only: bool = False + + # Tool descriptions (customizable for agent context) + tool_search_description: str = ( + "Search for records in the Redis vector database. " + "Supports semantic search, full-text search, and hybrid search." + ) + tool_upsert_description: str = ( + "Upsert records into the Redis vector database. " + "Records are automatically embedded and indexed." + ) +``` + +--- + +## Tools + +### Tool: `redisvl-search` + +Search for records using vector similarity, full-text, or hybrid search. + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | str | Yes | The search query text | +| `search_type` | str | No | One of: `vector`, `fulltext`, `hybrid`. Default: `vector` | +| `limit` | int | No | Maximum results to return. Default: 10 | +| `offset` | int | No | Pagination offset. Default: 0 | +| `filter` | dict | No | Filter expression (field conditions) | +| `return_fields` | list[str] | No | Fields to return. Default: all fields | + +#### Implementation + +```python +# redisvl/mcp/tools/search.py +from typing import Any, Dict, List, Optional +from mcp.server.fastmcp import Context + +async def search( + ctx: Context, + query: str, + search_type: str = "vector", + limit: int = 10, + offset: int = 0, + filter: Optional[Dict[str, Any]] = None, + return_fields: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + """Search for records in the Redis vector database.""" + server = ctx.server # RedisVLMCPServer instance + index = server.index + vectorizer = server.vectorizer + + if search_type == "vector": + # Generate embedding for query (as_buffer=True for efficient query integration) + embedding = await vectorizer.aembed(query, as_buffer=True) + + # Build VectorQuery + from redisvl.query import VectorQuery + q = VectorQuery( + vector=embedding, + vector_field_name=server.vector_field_name, + num_results=limit, + return_fields=return_fields, + ) + if filter: + q.set_filter(build_filter_expression(filter)) + + elif search_type == "fulltext": + from redisvl.query import TextQuery + q = TextQuery( + text=query, + text_field_name=server.text_field_name, + num_results=limit, + return_fields=return_fields, + ) + if filter: + q.set_filter(build_filter_expression(filter)) + + elif search_type == "hybrid": + # Generate embedding for query (as_buffer=True for efficient query integration) + embedding = await vectorizer.aembed(query, as_buffer=True) + from redisvl.query import HybridQuery + q = HybridQuery( + text=query, + text_field_name=server.text_field_name, + vector=embedding, + vector_field_name=server.vector_field_name, + num_results=limit, + ) + else: + raise ValueError(f"Invalid search_type: {search_type}") + + # Execute query with pagination + q.paging(offset, limit) + results = await index.query(q) + + return results +``` + +#### Response Format + +Returns a list of matching records: + +```json +[ + { + "id": "doc:123", + "score": 0.95, + "content": "The document text...", + "metadata_field": "value" + } +] +``` + +--- + +### Tool: `redisvl-upsert` + +Upsert records into the index. This tool is **excluded when `read_only=true`**. + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `records` | list[dict] | Yes | Records to upsert | +| `id_field` | str | No | Field to use as document ID | +| `embed_field` | str | No | Field containing text to embed. Default: auto-detect | + +#### Implementation + +```python +# redisvl/mcp/tools/upsert.py +from typing import Any, Dict, List, Optional +from mcp.server.fastmcp import Context + +async def upsert( + ctx: Context, + records: List[Dict[str, Any]], + id_field: Optional[str] = None, + embed_field: Optional[str] = None, +) -> Dict[str, Any]: + """Upsert records into the Redis vector database.""" + server = ctx.server + index = server.index + vectorizer = server.vectorizer + + # Determine which field to embed + if embed_field is None: + embed_field = server.default_embed_field + + # Generate embeddings for all records (as_buffer=True for storage efficiency) + texts_to_embed = [record.get(embed_field, "") for record in records] + embeddings = await vectorizer.aembed_many(texts_to_embed, as_buffer=True) + + # Add embeddings to records (already in buffer format for Redis storage) + vector_field = server.vector_field_name + for record, embedding in zip(records, embeddings): + record[vector_field] = embedding + + # Load records into index + keys = await index.load( + data=records, + id_field=id_field, + ) + + return { + "status": "success", + "keys_upserted": len(keys), + "keys": keys, + } +``` + +#### Response Format + +```json +{ + "status": "success", + "keys_upserted": 3, + "keys": ["doc:abc123", "doc:def456", "doc:ghi789"] +} +``` + +--- + +## Server Implementation + +### RedisVLMCPServer Class + +```python +# redisvl/mcp/server.py +from mcp.server.fastmcp import FastMCP +from redisvl.index import AsyncSearchIndex +from redisvl.mcp.settings import MCPSettings +from redisvl.mcp.config import load_mcp_config, create_index_schema, create_vectorizer + +class RedisVLMCPServer(FastMCP): + """MCP Server for RedisVL vector database operations.""" + + def __init__(self, settings: MCPSettings | None = None): + self.settings = settings or MCPSettings() + super().__init__(name="redisvl") + + # Load unified configuration + self._config = load_mcp_config(self.settings.config) + + # Initialize index and vectorizer lazily + self._index: AsyncSearchIndex | None = None + self._vectorizer = None + + # Register tools + self._setup_tools() + + async def _get_index(self) -> AsyncSearchIndex: + """Lazy initialization of the search index.""" + if self._index is None: + schema = create_index_schema(self._config) + redis_url = self._config.get("redis_url", "redis://localhost:6379") + self._index = AsyncSearchIndex( + schema=schema, + redis_url=redis_url, + ) + return self._index + + async def _get_vectorizer(self): + """Lazy initialization of the vectorizer.""" + if self._vectorizer is None: + self._vectorizer = create_vectorizer(self._config) + return self._vectorizer + + def _setup_tools(self): + """Register MCP tools.""" + from redisvl.mcp.tools.search import search + + # Always register search tool + self.tool( + search, + name="redisvl-search", + description=self.settings.tool_search_description, + ) + + # Conditionally register upsert tool + if not self.settings.read_only: + from redisvl.mcp.tools.upsert import upsert + self.tool( + upsert, + name="redisvl-upsert", + description=self.settings.tool_upsert_description, + ) + + @property + def index(self) -> AsyncSearchIndex: + """Access the search index (for tool implementations).""" + # Note: Tools should use await self._get_index() for lazy init + return self._index + + @property + def vectorizer(self): + """Access the vectorizer (for tool implementations).""" + return self._vectorizer +``` + +--- + +## CLI Integration + +### Command Structure + +```bash +# Start MCP server (stdio transport) - requires config file +rvl mcp --config path/to/mcp_config.yaml + +# Read-only mode (overrides config file setting) +rvl mcp --config path/to/mcp_config.yaml --read-only +``` + +### Implementation + +```python +# redisvl/cli/mcp.py +import argparse +import sys + +def add_mcp_parser(subparsers): + """Add MCP subcommand to CLI.""" + parser = subparsers.add_parser( + "mcp", + help="Start the RedisVL MCP server", + ) + parser.add_argument( + "--config", + type=str, + help="Path to MCP configuration YAML file (overrides REDISVL_MCP_CONFIG)", + ) + parser.add_argument( + "--read-only", + action="store_true", + help="Run in read-only mode (no upsert tool)", + ) + parser.set_defaults(func=run_mcp_server) + +def run_mcp_server(args): + """Run the MCP server.""" + try: + from redisvl.mcp import RedisVLMCPServer, MCPSettings + except ImportError: + print( + "MCP dependencies not installed. " + "Install with: pip install redisvl[mcp]", + file=sys.stderr, + ) + sys.exit(1) + + # Build settings from args + environment + settings_kwargs = {} + if args.config: + settings_kwargs["config"] = args.config + if args.read_only: + settings_kwargs["read_only"] = True + + settings = MCPSettings(**settings_kwargs) + server = RedisVLMCPServer(settings=settings) + + # Run with stdio transport + server.run(transport="stdio") +``` + +### Integration with Existing CLI + +Modify `redisvl/cli/main.py` to add the MCP subcommand: + +```python +# In create_parser() or equivalent +from redisvl.cli.mcp import add_mcp_parser +add_mcp_parser(subparsers) +``` + +--- + +## Client Configuration Examples + +### Claude Desktop + +```json +{ + "mcpServers": { + "redisvl": { + "command": "uvx", + "args": ["--from", "redisvl[mcp]", "rvl", "mcp", "--config", "/path/to/mcp_config.yaml"], + "env": { + "OPENAI_API_KEY": "sk-..." + } + } + } +} +``` + +Alternatively, use the environment variable for the config path: + +```json +{ + "mcpServers": { + "redisvl": { + "command": "uvx", + "args": ["--from", "redisvl[mcp]", "rvl", "mcp"], + "env": { + "REDISVL_MCP_CONFIG": "/path/to/mcp_config.yaml", + "OPENAI_API_KEY": "sk-..." + } + } + } +} +``` + +### Claude Agents SDK (Python) + +```python +import os +from agents import Agent +from agents.mcp import MCPServerStdio + +async def main(): + async with MCPServerStdio( + command="uvx", + args=["--from", "redisvl[mcp]", "rvl", "mcp", "--config", "mcp_config.yaml"], + env={ + "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], + }, + ) as server: + agent = Agent( + name="search-agent", + instructions="You help users search the knowledge base.", + mcp_servers=[server], + ) + # Use agent... +``` + +--- + +## Deliverables Mapping + +This specification maps to the project deliverables as follows: + +| Deliverable | Specification Section | LOE | +|-------------|----------------------|-----| +| MCP Server Framework in RedisVL | Server Implementation, Architecture | M | +| Tool: Search records | Tools > redisvl-search | S | +| Tool: Upsert records | Tools > redisvl-upsert | S | +| MCP runnable from CLI | CLI Integration | S | +| Integration: Claude Agents SDK | Client Configuration Examples | S | + +--- + +## Implementation Phases + +### Phase 1: Core Framework (M) + +1. Create `redisvl/mcp/` module structure +2. Implement `MCPSettings` with pydantic-settings +3. Implement `RedisVLMCPServer` extending FastMCP +4. Add `mcp` optional dependency group to pyproject.toml +5. Add basic tests for server initialization + +### Phase 2: Search Tool (S) + +1. Implement `redisvl-search` tool with vector search +2. Add full-text search support +3. Add hybrid search support +4. Add filter expression parsing +5. Add pagination support +6. Add tests for search functionality + +### Phase 3: Upsert Tool (S) + +1. Implement `redisvl-upsert` tool +2. Add automatic embedding generation +3. Add read-only mode exclusion logic +4. Add tests for upsert functionality + +### Phase 4: CLI Integration (S) + +1. Add `mcp` subcommand to CLI +2. Handle optional dependency import gracefully +3. Add CLI argument parsing +4. Test `uvx --from redisvl[mcp] rvl mcp` pattern + +### Phase 5: Integration Examples (S) + +1. Create Claude Agents SDK example +2. Document Claude Desktop configuration +3. (Bonus) Create ADK example +4. (Bonus) Create n8n workflow example + +--- + +## Testing Strategy + +### Unit Tests + +Location: `tests/unit/test_mcp/` + +- **Settings** (`test_settings.py`) + - Loading settings from environment variables + - Default values for optional settings + - Read-only mode flag handling + +- **Configuration** (`test_config.py`) + - YAML loading and parsing + - Environment variable substitution (`${VAR}` syntax) + - IndexSchema creation from config + - Vectorizer instantiation from class name + - Error handling for missing/invalid config + +### Integration Tests + +Location: `tests/integration/test_mcp/` + +Requires: Redis instance (use testcontainers) + +- **Server initialization** (`test_server.py`) + - Server starts with valid config + - Index connection established + - Tools registered correctly + - Read-only mode excludes upsert tool + +- **Search tool** (`test_search.py`) + - Vector search returns relevant results + - Full-text search works correctly + - Hybrid search combines both methods + - Pagination (offset/limit) works + - Filter expressions applied correctly + +- **Upsert tool** (`test_upsert.py`) + - Records inserted into Redis + - Embeddings generated and stored + - ID field used for key generation + - Records retrievable after upsert + +--- + +## Future Considerations + +### Additional Transport Protocols + +The current implementation supports only `stdio`. Future iterations may add: + +- **SSE (Server-Sent Events)**: For remote client connections +- **Streamable HTTP**: For web-based integrations + +### Additional Tools + +Future tools to consider: + +- `redisvl-delete`: Delete records by ID or filter +- `redisvl-count`: Count records matching a filter +- `redisvl-info`: Get index information and statistics +- `redisvl-aggregate`: Run aggregation queries + +### Multi-Index Support + +The current design supports a single index. Future iterations may support: + +- Multiple indexes via configuration +- Dynamic index selection in tool parameters + diff --git a/uv.lock b/uv.lock index 34277f94..0c567711 100644 --- a/uv.lock +++ b/uv.lock @@ -4771,7 +4771,7 @@ wheels = [ [[package]] name = "redisvl" -version = "0.14.1" +version = "0.15.0" source = { editable = "." } dependencies = [ { name = "jsonpath-ng" }, From fbc4615e2bf549fef445d6638ca2ab8ba89c15a9 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 3 Mar 2026 17:48:35 +0100 Subject: [PATCH 2/5] Next iteration of MCP spec --- spec/MCP.md | 936 +++++++++++++++++++++------------------------------- 1 file changed, 370 insertions(+), 566 deletions(-) diff --git a/spec/MCP.md b/spec/MCP.md index 4f26e6da..8e39a128 100644 --- a/spec/MCP.md +++ b/spec/MCP.md @@ -1,22 +1,50 @@ # RedisVL MCP Server Specification +## Document Status + +- Status: Draft for implementation +- Audience: RedisVL maintainers and coding agents implementing MCP support +- Primary objective: Define a deterministic, testable MCP server contract so agents can implement safely without relying on implicit behavior + +--- + ## Overview -This specification defines the implementation of a Model Context Protocol (MCP) server for RedisVL. The MCP server enables AI agents and LLM applications to interact with Redis as a vector database through a standardized protocol. +This specification defines a Model Context Protocol (MCP) server for RedisVL that allows MCP clients to search and upsert data in a Redis index. + +The server is designed for stdio transport first and must be runnable via: + +```bash +uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml +``` ### Goals -1. Expose RedisVL's vector search capabilities to MCP-compatible clients (Claude Desktop, Claude Agents SDK, etc.) -2. Provide tools for semantic search, full-text search, hybrid search, and data upsert -3. Integrate seamlessly with the existing RedisVL architecture -4. Support the `uvx --from redisvl rvl mcp` pattern for easy deployment +1. Expose RedisVL search capabilities (`vector`, `fulltext`, `hybrid`) through stable MCP tools. +2. Support controlled write access via an upsert tool. +3. Provide deterministic contracts for tool inputs, outputs, and errors. +4. Align implementation with existing RedisVL architecture and CLI patterns. -### References +### Non-Goals (v1) -- [Model Context Protocol Specification](https://modelcontextprotocol.io/) -- [FastMCP Library](https://github.com/jlowin/fastmcp) -- [Qdrant MCP Server](https://github.com/qdrant/mcp-server-qdrant) - Similar scope reference -- [Redis Agent Memory Server](https://github.com/redis-developer/agent-memory-server) - Implementation patterns +1. Multi-index routing in a single server process. +2. Remote transports (SSE/HTTP). +3. Delete/count/info tools (future scope). + +--- + +## Compatibility Matrix + +These are hard compatibility expectations for v1. + +| Component | Requirement | Notes | +|----------|-------------|-------| +| Python | `>=3.9.2,<3.15` | Match project constraints | +| RedisVL | current repo version | Server lives inside this package | +| redis-py | `>=5.0,<7.2` | Already required by project | +| MCP SDK | `mcp>=1.9.0` | Provides FastMCP | +| Redis server | Redis Stack / Redis with Search module | Required for all search modes | +| Hybrid search | Redis `>=8.4.0` and redis-py `>=7.1.0` runtime capability | If unavailable, `hybrid` returns structured error | --- @@ -24,74 +52,68 @@ This specification defines the implementation of a Model Context Protocol (MCP) ### Module Structure -``` +```text redisvl/ ├── mcp/ -│ ├── __init__.py # Public exports -│ ├── server.py # RedisVLMCPServer class (extends FastMCP) -│ ├── settings.py # MCPSettings (pydantic-settings) -│ ├── tools/ -│ │ ├── __init__.py -│ │ ├── search.py # Search tool implementation -│ │ └── upsert.py # Upsert tool implementation -│ └── utils.py # Helper functions -├── cli/ -│ └── ... (existing) # Add `mcp` subcommand +│ ├── __init__.py +│ ├── server.py # RedisVLMCPServer +│ ├── settings.py # MCPSettings +│ ├── config.py # Config models + loader + validation +│ ├── errors.py # MCP error mapping helpers +│ ├── filters.py # Filter DSL -> FilterExpression parser +│ └── tools/ +│ ├── __init__.py +│ ├── search.py # redisvl-search +│ └── upsert.py # redisvl-upsert +└── cli/ + ├── main.py # Add `mcp` command dispatch + └── mcp.py # MCP command handler class ``` -### Dependencies +### Dependency Groups -The MCP functionality is an **optional dependency group**: +Add optional extras for explicit install intent. ```toml -# pyproject.toml [project.optional-dependencies] mcp = [ - "mcp>=1.9.0", # MCP SDK with FastMCP + "mcp>=1.9.0", + "pydantic-settings>=2.0", ] ``` -Installation: `pip install redisvl[mcp]` - -### Core Components - -1. **RedisVLMCPServer**: Main server class extending `FastMCP` -2. **MCPSettings**: Configuration via environment variables (pydantic-settings) -3. **Tool implementations**: Search and upsert operations -4. **CLI integration**: `rvl mcp` subcommand +Notes: +- `fulltext`/`hybrid` use `TextQuery`/`HybridQuery`, which rely on NLTK stopwords when defaults are used. If `nltk` is not installed and stopwords are enabled, server must return a structured dependency error. +- Provider vectorizer dependencies remain provider-specific (`openai`, `cohere`, `vertexai`, etc.). --- -## Configuration (MCPSettings) +## Configuration -Settings are configured via environment variables, following the pattern established by Qdrant MCP and Agent Memory Server. +Configuration is composed from environment + YAML: + +1. `MCPSettings` from env/CLI. +2. YAML file referenced by `config` setting. +3. Env substitution inside YAML with strict validation. ### Environment Variables | Variable | Type | Default | Description | |----------|------|---------|-------------| -| `REDISVL_MCP_CONFIG` | str | (required) | Path to MCP configuration YAML file | -| `REDISVL_MCP_READ_ONLY` | bool | `false` | Disable upsert tool when true | -| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | str | (see below) | Custom search tool description | -| `REDISVL_MCP_TOOL_UPSERT_DESCRIPTION` | str | (see below) | Custom upsert tool description | - -### MCP Configuration File +| `REDISVL_MCP_CONFIG` | str | required | Path to MCP YAML config | +| `REDISVL_MCP_READ_ONLY` | bool | `false` | If true, do not register upsert tool | +| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | str | default text | MCP tool description override | +| `REDISVL_MCP_TOOL_UPSERT_DESCRIPTION` | str | default text | MCP tool description override | -All MCP server configuration is consolidated into a **single YAML file** that includes Redis connection, index schema, and vectorizer settings. This simplifies deployment and keeps related configuration together. - -#### Configuration File Format +### YAML Schema (Normative) ```yaml -# mcp_config.yaml - -# Redis connection redis_url: redis://localhost:6379 -# Index schema (inline, same format as existing RedisVL schemas) index: name: my_index prefix: doc - storage_type: hash # or "json" + storage_type: hash fields: - name: content @@ -106,323 +128,199 @@ fields: distance_metric: cosine datatype: float32 -# Vectorizer configuration -# Use the exact class name from redisvl.utils.vectorize vectorizer: - class: OpenAITextVectorizer # Required: vectorizer class name - model: text-embedding-3-small # Required: model name - - # Additional kwargs passed directly to the vectorizer constructor - # Most providers use environment variables by default for API keys + class: OpenAITextVectorizer + model: text-embedding-3-small + # kwargs passed to vectorizer constructor + # for providers using api_config, pass as nested object: + # api_config: + # api_key: ${OPENAI_API_KEY} + +runtime: + # index lifecycle mode: + # validate_only (default) | create_if_missing + index_mode: validate_only + + # required explicit field mapping for tool behavior + text_field_name: content + vector_field_name: embedding + default_embed_field: content + + # request constraints + default_limit: 10 + max_limit: 100 + + # timeouts + startup_timeout_seconds: 30 + request_timeout_seconds: 60 + + # server-side concurrency guard + max_concurrency: 16 ``` -#### Provider-Specific Vectorizer Examples +### Env Substitution Rules -```yaml -# OpenAI (simplest - uses OPENAI_API_KEY env var automatically) -vectorizer: - class: OpenAITextVectorizer - model: text-embedding-3-small +Supported patterns in YAML values: +- `${VAR}`: required variable. Fail startup if unset. +- `${VAR:-default}`: optional variable with fallback. -# Azure OpenAI -vectorizer: - class: AzureOpenAITextVectorizer - model: text-embedding-ada-002 - api_key: ${AZURE_OPENAI_API_KEY} - api_version: "2024-02-01" - azure_endpoint: ${AZURE_OPENAI_ENDPOINT} +Unresolved required vars must fail startup with config error. -# AWS Bedrock -vectorizer: - class: BedrockTextVectorizer - model: amazon.titan-embed-text-v1 - region_name: us-east-1 - # Uses AWS credentials from environment/IAM role by default +### Config Validation Rules -# Google VertexAI -vectorizer: - class: VertexAITextVectorizer - model: textembedding-gecko@003 - project_id: ${GCP_PROJECT_ID} - location: us-central1 +Server startup must fail fast if: +1. Config file missing/unreadable. +2. YAML invalid. +3. `runtime.text_field_name` not in schema. +4. `runtime.vector_field_name` not in schema or not vector type. +5. `runtime.default_embed_field` not in schema. +6. `default_limit <= 0` or `max_limit < default_limit`. -# HuggingFace (local embeddings) -vectorizer: - class: HFTextVectorizer - model: sentence-transformers/all-MiniLM-L6-v2 +--- -# Cohere -vectorizer: - class: CohereTextVectorizer - model: embed-english-v3.0 - # Uses COHERE_API_KEY env var automatically +## Lifecycle and Resource Management -# Mistral -vectorizer: - class: MistralAITextVectorizer - model: mistral-embed - # Uses MISTRAL_API_KEY env var automatically +### Startup Sequence (Normative) -# VoyageAI -vectorizer: - class: VoyageAITextVectorizer - model: voyage-2 - # Uses VOYAGE_API_KEY env var automatically -``` +On server startup: -### Configuration Loader +1. Load settings and config. +2. Build `IndexSchema`. +3. Create `AsyncSearchIndex` with `redis_url`. +4. Validate Redis connectivity by performing a lightweight call (`info` or equivalent search operation). +5. Handle index lifecycle: + - `validate_only`: verify index exists; fail if missing. + - `create_if_missing`: create index when absent; do not overwrite existing index. +6. Instantiate vectorizer. +7. Validate vectorizer dimensions match configured vector field dims when available. +8. Register tools (omit upsert in read-only mode). -```python -# redisvl/mcp/config.py -from typing import Any, Dict, Optional -import os -import re -import yaml - -from redisvl.schema import IndexSchema - -def load_mcp_config(config_path: str) -> Dict[str, Any]: - """Load MCP config with environment variable substitution.""" - with open(config_path) as f: - content = f.read() - - # Substitute ${VAR} patterns with environment variables - def replace_env(match): - var_name = match.group(1) - return os.environ.get(var_name, "") - - content = re.sub(r'\$\{(\w+)\}', replace_env, content) - return yaml.safe_load(content) - -def create_index_schema(config: Dict[str, Any]) -> IndexSchema: - """Create IndexSchema from the index/fields portion of config.""" - schema_dict = { - "index": config["index"], - "fields": config["fields"], - } - return IndexSchema.from_dict(schema_dict) +### Shutdown Sequence -def create_vectorizer(config: Dict[str, Any]): - """Create vectorizer instance from config using class name. +On shutdown, disconnect Redis client owned by `AsyncSearchIndex` and release vectorizer resources if applicable. - The vectorizer config should have: - - class: The exact class name (e.g., "OpenAITextVectorizer") - - model: The model name - - Any additional kwargs are passed to the constructor - """ - vec_config = config.get("vectorizer", {}).copy() +### Concurrency Guard - class_name = vec_config.pop("class", None) - if not class_name: - raise ValueError("Vectorizer 'class' is required in configuration") +Tool executions are bounded by an async semaphore (`runtime.max_concurrency`). Requests exceeding capacity wait, then may timeout according to `request_timeout_seconds`. - # Import the vectorizer class dynamically - import redisvl.utils.vectorize as vectorize_module +--- - if not hasattr(vectorize_module, class_name): - raise ValueError( - f"Unknown vectorizer class: {class_name}. " - f"Must be a class from redisvl.utils.vectorize" - ) +## Filter DSL (Normative) + +`redisvl-search.filter` accepts JSON in this DSL. + +### Operators + +- Logical: `and`, `or`, `not` +- Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like` +- Utility: `exists` - vectorizer_class = getattr(vectorize_module, class_name) +### Atomic Expression Shape - # All remaining config keys are passed as kwargs to the constructor - return vectorizer_class(**vec_config) +```json +{ "field": "category", "op": "eq", "value": "science" } ``` -### Settings Class +### Composite Shape -```python -# redisvl/mcp/settings.py -from pydantic_settings import BaseSettings, SettingsConfigDict -from typing import Optional - -class MCPSettings(BaseSettings): - model_config = SettingsConfigDict( - env_prefix="REDISVL_MCP_", - env_file=".env", - extra="ignore", - ) - - # Path to unified MCP configuration file - config: str # Required: path to mcp_config.yaml - - # Server mode (can also be set in config file, env var takes precedence) - read_only: bool = False - - # Tool descriptions (customizable for agent context) - tool_search_description: str = ( - "Search for records in the Redis vector database. " - "Supports semantic search, full-text search, and hybrid search." - ) - tool_upsert_description: str = ( - "Upsert records into the Redis vector database. " - "Records are automatically embedded and indexed." - ) +```json +{ + "and": [ + { "field": "category", "op": "eq", "value": "science" }, + { + "or": [ + { "field": "rating", "op": "gte", "value": 4.5 }, + { "field": "is_pinned", "op": "eq", "value": true } + ] + } + ] +} ``` +### Parsing Rules + +1. Unknown `op` fails with `invalid_filter`. +2. Unknown `field` fails with `invalid_filter`. +3. Type mismatches fail with `invalid_filter`. +4. Empty logical arrays fail with `invalid_filter`. +5. Parser translates DSL to `redisvl.query.filter.FilterExpression`. + --- ## Tools -### Tool: `redisvl-search` +## Tool: `redisvl-search` -Search for records using vector similarity, full-text, or hybrid search. +Search records using vector, full-text, or hybrid query. -#### Parameters +### Request Contract -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `query` | str | Yes | The search query text | -| `search_type` | str | No | One of: `vector`, `fulltext`, `hybrid`. Default: `vector` | -| `limit` | int | No | Maximum results to return. Default: 10 | -| `offset` | int | No | Pagination offset. Default: 0 | -| `filter` | dict | No | Filter expression (field conditions) | -| `return_fields` | list[str] | No | Fields to return. Default: all fields | +| Parameter | Type | Required | Default | Constraints | +|----------|------|----------|---------|-------------| +| `query` | str | yes | - | non-empty | +| `search_type` | enum | no | `vector` | `vector` \| `fulltext` \| `hybrid` | +| `limit` | int | no | `runtime.default_limit` | `1..runtime.max_limit` | +| `offset` | int | no | `0` | `>=0` | +| `filter` | object | no | `null` | Must satisfy filter DSL | +| `return_fields` | list[str] | no | all non-vector fields | Unknown fields rejected | -#### Implementation +### Response Contract -```python -# redisvl/mcp/tools/search.py -from typing import Any, Dict, List, Optional -from mcp.server.fastmcp import Context - -async def search( - ctx: Context, - query: str, - search_type: str = "vector", - limit: int = 10, - offset: int = 0, - filter: Optional[Dict[str, Any]] = None, - return_fields: Optional[List[str]] = None, -) -> List[Dict[str, Any]]: - """Search for records in the Redis vector database.""" - server = ctx.server # RedisVLMCPServer instance - index = server.index - vectorizer = server.vectorizer - - if search_type == "vector": - # Generate embedding for query (as_buffer=True for efficient query integration) - embedding = await vectorizer.aembed(query, as_buffer=True) - - # Build VectorQuery - from redisvl.query import VectorQuery - q = VectorQuery( - vector=embedding, - vector_field_name=server.vector_field_name, - num_results=limit, - return_fields=return_fields, - ) - if filter: - q.set_filter(build_filter_expression(filter)) - - elif search_type == "fulltext": - from redisvl.query import TextQuery - q = TextQuery( - text=query, - text_field_name=server.text_field_name, - num_results=limit, - return_fields=return_fields, - ) - if filter: - q.set_filter(build_filter_expression(filter)) - - elif search_type == "hybrid": - # Generate embedding for query (as_buffer=True for efficient query integration) - embedding = await vectorizer.aembed(query, as_buffer=True) - from redisvl.query import HybridQuery - q = HybridQuery( - text=query, - text_field_name=server.text_field_name, - vector=embedding, - vector_field_name=server.vector_field_name, - num_results=limit, - ) - else: - raise ValueError(f"Invalid search_type: {search_type}") - - # Execute query with pagination - q.paging(offset, limit) - results = await index.query(q) - - return results +```json +{ + "search_type": "vector", + "offset": 0, + "limit": 10, + "results": [ + { + "id": "doc:123", + "score": 0.93, + "score_type": "vector_distance_normalized", + "record": { + "content": "The document text...", + "category": "science" + } + } + ] +} ``` -#### Response Format +### Search Semantics -Returns a list of matching records: +- `vector`: embeds `query` with configured vectorizer, builds `VectorQuery`. +- `fulltext`: builds `TextQuery`. +- `hybrid`: embeds `query`, builds `HybridQuery`. +- `hybrid` must fail with structured capability error if runtime support is unavailable. -```json -[ - { - "id": "doc:123", - "score": 0.95, - "content": "The document text...", - "metadata_field": "value" - } -] -``` +### Errors ---- +| Code | Meaning | Retryable | +|------|---------|-----------| +| `invalid_request` | bad query params | no | +| `invalid_filter` | filter parse/type failure | no | +| `dependency_missing` | missing optional lib/provider SDK | no | +| `capability_unavailable` | hybrid unsupported in runtime | no | +| `backend_unavailable` | Redis unavailable/timeout | yes | +| `internal_error` | unexpected failure | maybe | -### Tool: `redisvl-upsert` +--- -Upsert records into the index. This tool is **excluded when `read_only=true`**. +## Tool: `redisvl-upsert` -#### Parameters +Upsert records with automatic embedding. -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `records` | list[dict] | Yes | Records to upsert | -| `id_field` | str | No | Field to use as document ID | -| `embed_field` | str | No | Field containing text to embed. Default: auto-detect | +Not registered when read-only mode is enabled. -#### Implementation +### Request Contract -```python -# redisvl/mcp/tools/upsert.py -from typing import Any, Dict, List, Optional -from mcp.server.fastmcp import Context - -async def upsert( - ctx: Context, - records: List[Dict[str, Any]], - id_field: Optional[str] = None, - embed_field: Optional[str] = None, -) -> Dict[str, Any]: - """Upsert records into the Redis vector database.""" - server = ctx.server - index = server.index - vectorizer = server.vectorizer - - # Determine which field to embed - if embed_field is None: - embed_field = server.default_embed_field - - # Generate embeddings for all records (as_buffer=True for storage efficiency) - texts_to_embed = [record.get(embed_field, "") for record in records] - embeddings = await vectorizer.aembed_many(texts_to_embed, as_buffer=True) - - # Add embeddings to records (already in buffer format for Redis storage) - vector_field = server.vector_field_name - for record, embedding in zip(records, embeddings): - record[vector_field] = embedding - - # Load records into index - keys = await index.load( - data=records, - id_field=id_field, - ) - - return { - "status": "success", - "keys_upserted": len(keys), - "keys": keys, - } -``` +| Parameter | Type | Required | Default | Constraints | +|----------|------|----------|---------|-------------| +| `records` | list[object] | yes | - | non-empty | +| `id_field` | str | no | `null` | if set, must exist in every record | +| `embed_field` | str | no | `runtime.default_embed_field` | must exist in every record | +| `skip_embedding_if_present` | bool | no | `true` | if false, always re-embed | -#### Response Format +### Response Contract ```json { @@ -432,159 +330,69 @@ async def upsert( } ``` +### Upsert Semantics + +1. Validate input records before writing. +2. Resolve `embed_field`. +3. Generate embeddings for required records (`aembed_many`). +4. Populate configured vector field. +5. Call `AsyncSearchIndex.load`. + +### Error Semantics + +- Validation failures return `invalid_request`. +- Provider errors return `dependency_missing` or `internal_error` with actionable message. +- Redis write failures return `backend_unavailable`. +- On write failure, response must include `partial_write_possible: true` (conservative signal). + --- ## Server Implementation -### RedisVLMCPServer Class +### Core Class Contract ```python -# redisvl/mcp/server.py -from mcp.server.fastmcp import FastMCP -from redisvl.index import AsyncSearchIndex -from redisvl.mcp.settings import MCPSettings -from redisvl.mcp.config import load_mcp_config, create_index_schema, create_vectorizer - class RedisVLMCPServer(FastMCP): - """MCP Server for RedisVL vector database operations.""" - - def __init__(self, settings: MCPSettings | None = None): - self.settings = settings or MCPSettings() - super().__init__(name="redisvl") - - # Load unified configuration - self._config = load_mcp_config(self.settings.config) - - # Initialize index and vectorizer lazily - self._index: AsyncSearchIndex | None = None - self._vectorizer = None - - # Register tools - self._setup_tools() - - async def _get_index(self) -> AsyncSearchIndex: - """Lazy initialization of the search index.""" - if self._index is None: - schema = create_index_schema(self._config) - redis_url = self._config.get("redis_url", "redis://localhost:6379") - self._index = AsyncSearchIndex( - schema=schema, - redis_url=redis_url, - ) - return self._index - - async def _get_vectorizer(self): - """Lazy initialization of the vectorizer.""" - if self._vectorizer is None: - self._vectorizer = create_vectorizer(self._config) - return self._vectorizer - - def _setup_tools(self): - """Register MCP tools.""" - from redisvl.mcp.tools.search import search - - # Always register search tool - self.tool( - search, - name="redisvl-search", - description=self.settings.tool_search_description, - ) + settings: MCPSettings + config: MCPConfig - # Conditionally register upsert tool - if not self.settings.read_only: - from redisvl.mcp.tools.upsert import upsert - self.tool( - upsert, - name="redisvl-upsert", - description=self.settings.tool_upsert_description, - ) - - @property - def index(self) -> AsyncSearchIndex: - """Access the search index (for tool implementations).""" - # Note: Tools should use await self._get_index() for lazy init - return self._index - - @property - def vectorizer(self): - """Access the vectorizer (for tool implementations).""" - return self._vectorizer -``` + async def startup(self) -> None: ... + async def shutdown(self) -> None: ... ---- + async def get_index(self) -> AsyncSearchIndex: ... + async def get_vectorizer(self): ... +``` -## CLI Integration +Tool implementations must always call `await server.get_index()` and `await server.get_vectorizer()`; never read uninitialized attributes directly. -### Command Structure +### Field Mapping Requirements -```bash -# Start MCP server (stdio transport) - requires config file -rvl mcp --config path/to/mcp_config.yaml +Server owns these validated values: +- `text_field_name` +- `vector_field_name` +- `default_embed_field` -# Read-only mode (overrides config file setting) -rvl mcp --config path/to/mcp_config.yaml --read-only -``` +No implicit auto-detection is allowed in v1. -### Implementation +--- -```python -# redisvl/cli/mcp.py -import argparse -import sys - -def add_mcp_parser(subparsers): - """Add MCP subcommand to CLI.""" - parser = subparsers.add_parser( - "mcp", - help="Start the RedisVL MCP server", - ) - parser.add_argument( - "--config", - type=str, - help="Path to MCP configuration YAML file (overrides REDISVL_MCP_CONFIG)", - ) - parser.add_argument( - "--read-only", - action="store_true", - help="Run in read-only mode (no upsert tool)", - ) - parser.set_defaults(func=run_mcp_server) - -def run_mcp_server(args): - """Run the MCP server.""" - try: - from redisvl.mcp import RedisVLMCPServer, MCPSettings - except ImportError: - print( - "MCP dependencies not installed. " - "Install with: pip install redisvl[mcp]", - file=sys.stderr, - ) - sys.exit(1) +## CLI Integration - # Build settings from args + environment - settings_kwargs = {} - if args.config: - settings_kwargs["config"] = args.config - if args.read_only: - settings_kwargs["read_only"] = True +Current RedisVL CLI is command-dispatch based (not argparse subparsers), so MCP integration must follow existing pattern. - settings = MCPSettings(**settings_kwargs) - server = RedisVLMCPServer(settings=settings) +### User Commands - # Run with stdio transport - server.run(transport="stdio") +```bash +rvl mcp --config path/to/mcp_config.yaml +rvl mcp --config path/to/mcp_config.yaml --read-only ``` -### Integration with Existing CLI +### Required CLI Changes -Modify `redisvl/cli/main.py` to add the MCP subcommand: - -```python -# In create_parser() or equivalent -from redisvl.cli.mcp import add_mcp_parser -add_mcp_parser(subparsers) -``` +1. Add `mcp` command to usage/help in `redisvl/cli/main.py`. +2. Add `RedisVlCLI.mcp()` method that dispatches to new `MCP` handler class. +3. Implement `redisvl/cli/mcp.py` similar to existing command modules. +4. Gracefully report missing optional deps (`pip install redisvl[mcp]`). --- @@ -606,27 +414,9 @@ add_mcp_parser(subparsers) } ``` -Alternatively, use the environment variable for the config path: - -```json -{ - "mcpServers": { - "redisvl": { - "command": "uvx", - "args": ["--from", "redisvl[mcp]", "rvl", "mcp"], - "env": { - "REDISVL_MCP_CONFIG": "/path/to/mcp_config.yaml", - "OPENAI_API_KEY": "sk-..." - } - } - } -} -``` - ### Claude Agents SDK (Python) ```python -import os from agents import Agent from agents.mcp import MCPServerStdio @@ -634,143 +424,157 @@ async def main(): async with MCPServerStdio( command="uvx", args=["--from", "redisvl[mcp]", "rvl", "mcp", "--config", "mcp_config.yaml"], - env={ - "OPENAI_API_KEY": os.environ["OPENAI_API_KEY"], - }, ) as server: agent = Agent( name="search-agent", - instructions="You help users search the knowledge base.", + instructions="Search and maintain Redis-backed knowledge.", mcp_servers=[server], ) - # Use agent... ``` --- -## Deliverables Mapping - -This specification maps to the project deliverables as follows: - -| Deliverable | Specification Section | LOE | -|-------------|----------------------|-----| -| MCP Server Framework in RedisVL | Server Implementation, Architecture | M | -| Tool: Search records | Tools > redisvl-search | S | -| Tool: Upsert records | Tools > redisvl-upsert | S | -| MCP runnable from CLI | CLI Integration | S | -| Integration: Claude Agents SDK | Client Configuration Examples | S | - ---- - -## Implementation Phases +## Observability and Security -### Phase 1: Core Framework (M) +### Logging -1. Create `redisvl/mcp/` module structure -2. Implement `MCPSettings` with pydantic-settings -3. Implement `RedisVLMCPServer` extending FastMCP -4. Add `mcp` optional dependency group to pyproject.toml -5. Add basic tests for server initialization +- Use structured logs with operation name, latency, and error code. +- Never log secrets (API keys, auth headers, full DSNs with credentials). +- Log config path but not raw config values for sensitive keys. -### Phase 2: Search Tool (S) +### Timeouts -1. Implement `redisvl-search` tool with vector search -2. Add full-text search support -3. Add hybrid search support -4. Add filter expression parsing -5. Add pagination support -6. Add tests for search functionality +- Startup timeout: `runtime.startup_timeout_seconds` +- Tool request timeout: `runtime.request_timeout_seconds` -### Phase 3: Upsert Tool (S) +### Secret Handling -1. Implement `redisvl-upsert` tool -2. Add automatic embedding generation -3. Add read-only mode exclusion logic -4. Add tests for upsert functionality +- Support env-injected secrets via `${VAR}` substitution. +- Fail fast for required missing vars. -### Phase 4: CLI Integration (S) +--- -1. Add `mcp` subcommand to CLI -2. Handle optional dependency import gracefully -3. Add CLI argument parsing -4. Test `uvx --from redisvl[mcp] rvl mcp` pattern +## Testing Strategy -### Phase 5: Integration Examples (S) +## Unit Tests (`tests/unit/test_mcp/`) + +- `test_settings.py` + - env parsing and overrides + - read-only behavior +- `test_config.py` + - YAML validation + - env substitution success/failure + - field mapping validation +- `test_filters.py` + - DSL parsing, invalid operators, type mismatches +- `test_errors.py` + - internal exception -> MCP error code mapping + +## Integration Tests (`tests/integration/test_mcp/`) + +- `test_server_startup.py` + - startup success path + - missing index in `validate_only` + - create in `create_if_missing` +- `test_search_tool.py` + - vector/fulltext/hybrid success paths + - hybrid capability failure path + - pagination and field projection + - filter behavior +- `test_upsert_tool.py` + - insert/update success + - id_field/embed_field validation failures + - read-only mode excludes tool + +### Deterministic Verification Commands -1. Create Claude Agents SDK example -2. Document Claude Desktop configuration -3. (Bonus) Create ADK example -4. (Bonus) Create n8n workflow example +```bash +uv run python -m pytest tests/unit/test_mcp -q +uv run python -m pytest tests/integration/test_mcp -q +``` --- -## Testing Strategy +## Implementation Plan and DoD -### Unit Tests +### Phase 1: Framework -Location: `tests/unit/test_mcp/` +Deliverables: +1. `redisvl/mcp/` scaffolding. +2. Config/settings models with strict validation. +3. Startup/shutdown lifecycle. +4. Error mapping helpers. -- **Settings** (`test_settings.py`) - - Loading settings from environment variables - - Default values for optional settings - - Read-only mode flag handling +DoD: +1. Server boots successfully with valid config. +2. Server fails fast with actionable config errors. +3. Unit tests for config/settings pass. -- **Configuration** (`test_config.py`) - - YAML loading and parsing - - Environment variable substitution (`${VAR}` syntax) - - IndexSchema creation from config - - Vectorizer instantiation from class name - - Error handling for missing/invalid config +### Phase 2: Search Tool -### Integration Tests +Deliverables: +1. `redisvl-search` request/response contract. +2. Filter DSL parser. +3. Capability checks for hybrid support. -Location: `tests/integration/test_mcp/` +DoD: +1. All search modes tested. +2. Invalid filter returns `invalid_filter`. +3. Capability failures are deterministic and non-ambiguous. -Requires: Redis instance (use testcontainers) +### Phase 3: Upsert Tool -- **Server initialization** (`test_server.py`) - - Server starts with valid config - - Index connection established - - Tools registered correctly - - Read-only mode excludes upsert tool +Deliverables: +1. `redisvl-upsert` implementation. +2. Record pre-validation. +3. Read-only exclusion. -- **Search tool** (`test_search.py`) - - Vector search returns relevant results - - Full-text search works correctly - - Hybrid search combines both methods - - Pagination (offset/limit) works - - Filter expressions applied correctly +DoD: +1. Upsert works end-to-end. +2. Invalid records fail before writes. +3. Read-only mode verified. -- **Upsert tool** (`test_upsert.py`) - - Records inserted into Redis - - Embeddings generated and stored - - ID field used for key generation - - Records retrievable after upsert +### Phase 4: CLI and Packaging ---- +Deliverables: +1. `rvl mcp` command via current CLI pattern. +2. Optional dependency group updates. +3. User-facing error messages for missing extras. -## Future Considerations +DoD: +1. `uvx --from redisvl[mcp] rvl mcp --config ...` runs successfully. +2. CLI help includes `mcp` command. -### Additional Transport Protocols +### Phase 5: Documentation -The current implementation supports only `stdio`. Future iterations may add: +Deliverables: +1. Config reference and examples. +2. Client setup examples. +3. Troubleshooting guide with common errors and fixes. -- **SSE (Server-Sent Events)**: For remote client connections -- **Streamable HTTP**: For web-based integrations +DoD: +1. Docs reflect normative contracts in this spec. +2. Examples are executable and tested. -### Additional Tools +--- -Future tools to consider: +## Risks and Mitigations -- `redisvl-delete`: Delete records by ID or filter -- `redisvl-count`: Count records matching a filter -- `redisvl-info`: Get index information and statistics -- `redisvl-aggregate`: Run aggregation queries +1. Runtime mismatch for hybrid search. + - Mitigation: explicit capability check + clear error code. +2. Dependency drift across provider vectorizers. + - Mitigation: dependency matrix and startup validation. +3. Ambiguous filter behavior causing agent retries. + - Mitigation: strict DSL and deterministic parser errors. +4. Hidden partial writes during failures. + - Mitigation: conservative `partial_write_possible` signaling. -### Multi-Index Support +--- -The current design supports a single index. Future iterations may support: +## Open Design Questions -- Multiple indexes via configuration -- Dynamic index selection in tool parameters +1. Should `upsert` preserve user-provided vectors by default when the vector field already exists (`skip_embedding_if_present=true`), or always re-embed? +2. Do we want `index_mode=create_if_missing` as the default instead of `validate_only`? +3. Should v1 support string-based raw Redis filter expressions in addition to the JSON filter DSL, or keep JSON-only? +4. Is there a hard maximum payload size for `records` in one upsert request (count/bytes) for guardrails? From eb7c1698cc02f7642a11571c7f4ce17deec35ac8 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 3 Mar 2026 17:53:41 +0100 Subject: [PATCH 3/5] Implement design decisions --- spec/MCP.md | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/spec/MCP.md b/spec/MCP.md index 8e39a128..c0750488 100644 --- a/spec/MCP.md +++ b/spec/MCP.md @@ -60,7 +60,7 @@ redisvl/ │ ├── settings.py # MCPSettings │ ├── config.py # Config models + loader + validation │ ├── errors.py # MCP error mapping helpers -│ ├── filters.py # Filter DSL -> FilterExpression parser +│ ├── filters.py # Filter parser (DSL + raw string handling) │ └── tools/ │ ├── __init__.py │ ├── search.py # redisvl-search @@ -138,8 +138,8 @@ vectorizer: runtime: # index lifecycle mode: - # validate_only (default) | create_if_missing - index_mode: validate_only + # validate_only | create_if_missing (default) + index_mode: create_if_missing # required explicit field mapping for tool behavior text_field_name: content @@ -149,6 +149,10 @@ runtime: # request constraints default_limit: 10 max_limit: 100 + max_upsert_records: 64 + + # default overwrite behavior for existing vectors + skip_embedding_if_present: true # timeouts startup_timeout_seconds: 30 @@ -175,6 +179,7 @@ Server startup must fail fast if: 4. `runtime.vector_field_name` not in schema or not vector type. 5. `runtime.default_embed_field` not in schema. 6. `default_limit <= 0` or `max_limit < default_limit`. +7. `max_upsert_records <= 0`. --- @@ -205,9 +210,11 @@ Tool executions are bounded by an async semaphore (`runtime.max_concurrency`). R --- -## Filter DSL (Normative) +## Filter Contract (Normative) -`redisvl-search.filter` accepts JSON in this DSL. +`redisvl-search.filter` follows RedisVL convention and accepts either: +- `string`: raw RedisVL/RediSearch filter string (passed through to query filter). +- `object`: JSON DSL described below. ### Operators @@ -243,7 +250,8 @@ Tool executions are bounded by an async semaphore (`runtime.max_concurrency`). R 2. Unknown `field` fails with `invalid_filter`. 3. Type mismatches fail with `invalid_filter`. 4. Empty logical arrays fail with `invalid_filter`. -5. Parser translates DSL to `redisvl.query.filter.FilterExpression`. +5. Object DSL parser translates to `redisvl.query.filter.FilterExpression`. +6. String filter is treated as raw filter expression and passed through. --- @@ -261,7 +269,7 @@ Search records using vector, full-text, or hybrid query. | `search_type` | enum | no | `vector` | `vector` \| `fulltext` \| `hybrid` | | `limit` | int | no | `runtime.default_limit` | `1..runtime.max_limit` | | `offset` | int | no | `0` | `>=0` | -| `filter` | object | no | `null` | Must satisfy filter DSL | +| `filter` | string \\| object | no | `null` | Raw RedisVL filter string or DSL object | | `return_fields` | list[str] | no | all non-vector fields | Unknown fields rejected | ### Response Contract @@ -315,10 +323,10 @@ Not registered when read-only mode is enabled. | Parameter | Type | Required | Default | Constraints | |----------|------|----------|---------|-------------| -| `records` | list[object] | yes | - | non-empty | +| `records` | list[object] | yes | - | non-empty and `len(records) <= runtime.max_upsert_records` | | `id_field` | str | no | `null` | if set, must exist in every record | | `embed_field` | str | no | `runtime.default_embed_field` | must exist in every record | -| `skip_embedding_if_present` | bool | no | `true` | if false, always re-embed | +| `skip_embedding_if_present` | bool | no | `runtime.skip_embedding_if_present` | if false, always re-embed | ### Response Contract @@ -334,7 +342,7 @@ Not registered when read-only mode is enabled. 1. Validate input records before writing. 2. Resolve `embed_field`. -3. Generate embeddings for required records (`aembed_many`). +3. Respect `skip_embedding_if_present` (default true): only generate embeddings for records missing configured vector field. 4. Populate configured vector field. 5. Call `AsyncSearchIndex.load`. @@ -514,7 +522,7 @@ DoD: Deliverables: 1. `redisvl-search` request/response contract. -2. Filter DSL parser. +2. Filter parser (JSON DSL + raw string pass-through). 3. Capability checks for hybrid support. DoD: @@ -565,16 +573,6 @@ DoD: 2. Dependency drift across provider vectorizers. - Mitigation: dependency matrix and startup validation. 3. Ambiguous filter behavior causing agent retries. - - Mitigation: strict DSL and deterministic parser errors. + - Mitigation: explicit raw-string pass-through semantics and deterministic DSL parser errors. 4. Hidden partial writes during failures. - Mitigation: conservative `partial_write_possible` signaling. - ---- - -## Open Design Questions - -1. Should `upsert` preserve user-provided vectors by default when the vector field already exists (`skip_embedding_if_present=true`), or always re-embed? -2. Do we want `index_mode=create_if_missing` as the default instead of `validate_only`? -3. Should v1 support string-based raw Redis filter expressions in addition to the JSON filter DSL, or keep JSON-only? -4. Is there a hard maximum payload size for `records` in one upsert request (count/bytes) for guardrails? - From 3d2192ad648f92823fca93bf82df9c3fef5bfa61 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 3 Mar 2026 17:59:27 +0100 Subject: [PATCH 4/5] Switch to skill-style doc metadata at top [skip ci] --- spec/MCP.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/MCP.md b/spec/MCP.md index c0750488..7d159c66 100644 --- a/spec/MCP.md +++ b/spec/MCP.md @@ -1,12 +1,13 @@ -# RedisVL MCP Server Specification - -## Document Status - -- Status: Draft for implementation -- Audience: RedisVL maintainers and coding agents implementing MCP support -- Primary objective: Define a deterministic, testable MCP server contract so agents can implement safely without relying on implicit behavior - --- +name: redisvl-mcp-server-spec +description: Implementation specification for a RedisVL MCP server with deterministic, agent-friendly contracts for development and testing. +metadata: + status: draft + audience: RedisVL maintainers and coding agents + objective: Define a deterministic, testable MCP server contract so agents can implement safely without relying on implicit behavior. +--- + +# RedisVL MCP Server Specification ## Overview From 2f58b0fde9d53a49ac867778463cac7f26c94986 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Tue, 3 Mar 2026 18:16:13 +0100 Subject: [PATCH 5/5] Add ADK and n8n integrations to plan;security gaps to risks [skip ci] --- spec/MCP.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/spec/MCP.md b/spec/MCP.md index 7d159c66..df49a3f4 100644 --- a/spec/MCP.md +++ b/spec/MCP.md @@ -441,6 +441,59 @@ async def main(): ) ``` +### Google ADK (Python) + +```python +from google.adk.agents import LlmAgent +from google.adk.tools.mcp_tool import McpToolset +from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams +from mcp import StdioServerParameters + +root_agent = LlmAgent( + model="gemini-2.0-flash", + name="redis_search_agent", + instruction="Search and maintain Redis-backed knowledge using vector search.", + tools=[ + McpToolset( + connection_params=StdioConnectionParams( + server_params=StdioServerParameters( + command="uvx", + args=["--from", "redisvl[mcp]", "rvl", "mcp", "--config", "/path/to/mcp_config.yaml"], + env={ + "OPENAI_API_KEY": "sk-..." # Or other vectorizer API key + } + ), + ), + # Optional: filter to specific tools + # tool_filter=["redisvl-search"] + ) + ], +) +``` + +### n8n + +n8n supports MCP servers via the MCP Server Trigger node. Configure the RedisVL MCP server as an external MCP tool source: + +1. **Using SSE transport** (if supported in future versions): + ```json + { + "mcpServers": { + "redisvl": { + "url": "http://localhost:9000/sse" + } + } + } + ``` + +2. **Using stdio transport** (via n8n's Execute Command node as a workaround): + Configure a workflow that spawns the MCP server process: + ```bash + uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml + ``` + +Note: Full n8n MCP client support depends on n8n's MCP implementation. Refer to [n8n MCP documentation](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger/) for current capabilities. + --- ## Observability and Security @@ -577,3 +630,13 @@ DoD: - Mitigation: explicit raw-string pass-through semantics and deterministic DSL parser errors. 4. Hidden partial writes during failures. - Mitigation: conservative `partial_write_possible` signaling. +5. Security and deployment limitations (v1 scope). + - This implementation is designed for local/development usage via stdio transport. It does not include: + - Authentication/authorization mechanisms (unlike Redis Agent Memory Server which supports OAuth2/JWT). + - Remote transports (SSE/HTTP) that would enable multi-tenant or networked deployments. + - Rate limiting or request validation beyond basic input constraints. + - Mitigation: Document clearly that v1 is intended for local, single-user scenarios. Users requiring production-grade security should consider the official Redis MCP server or wait for future RedisVL MCP versions that may add remote transport and auth support. + - For production deployments requiring authentication, users can: + - Deploy behind an authenticating proxy. + - Use environment-based secrets for Redis and vectorizer credentials. + - Restrict network access to the MCP server process.