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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ This MCP Server provides tools to manage the data stored in Redis.

Additional tools.

- `docs` tool to search Redis documentation, tutorials, and best practices using natural language questions (backed by the `MCP_DOCS_SEARCH_URL` HTTP API).
- `query engine` tools to manage vector indexes and perform vector search
- `server management` tool to retrieve information about the database

Expand Down Expand Up @@ -354,6 +355,7 @@ If desired, you can use environment variables. Defaults are provided for all var
| `REDIS_SSL_CA_CERTS` | Path to the trusted CA certificates file | None |
| `REDIS_CLUSTER_MODE` | Enable Redis Cluster mode | `False` |


### EntraID Authentication for Azure Managed Redis

The Redis MCP Server supports **EntraID (Azure Active Directory) authentication** for Azure Managed Redis, enabling OAuth-based authentication with automatic token management.
Expand Down Expand Up @@ -670,8 +672,8 @@ For more information, see the [VS Code documentation](https://code.visualstudio.

> **Tip:** You can prompt Copilot chat to use the Redis MCP tools by including `#redis` in your message.

> **Note:** Starting with [VS Code v1.102](https://code.visualstudio.com/updates/v1_102),
> MCP servers are now stored in a dedicated `mcp.json` file instead of `settings.json`.
> **Note:** Starting with [VS Code v1.102](https://code.visualstudio.com/updates/v1_102),
> MCP servers are now stored in a dedicated `mcp.json` file instead of `settings.json`.

## Testing

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"dotenv>=0.9.9",
"numpy>=2.2.4",
"click>=8.0.0",
"aiohttp>=3.13.0",
"redis-entraid>=1.0.0",
]

Expand Down
5 changes: 5 additions & 0 deletions src/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"resource": os.getenv("REDIS_ENTRAID_RESOURCE", "https://redis.azure.com/"),
}

# ConvAI API configuration
MCP_DOCS_SEARCH_URL = os.getenv(
"MCP_DOCS_SEARCH_URL", "https://redis.io/convai/api/docs/search"
)


def parse_redis_uri(uri: str) -> dict:
"""Parse a Redis URI and return connection parameters."""
Expand Down
2 changes: 1 addition & 1 deletion src/common/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def load_tools():


# Initialize FastMCP server
mcp = FastMCP("Redis MCP Server", dependencies=["redis", "dotenv", "numpy"])
mcp = FastMCP("Redis MCP Server", dependencies=["redis", "dotenv", "numpy", "aiohttp"])

# Load tools
load_tools()
59 changes: 59 additions & 0 deletions src/tools/misc.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from typing import Any, Dict, Union, List
import aiohttp

from redis.exceptions import RedisError

from src.common.connection import RedisConnectionManager
from src.common.server import mcp
from src.common.config import MCP_DOCS_SEARCH_URL
from src.version import __version__


@mcp.tool()
Expand Down Expand Up @@ -194,3 +197,59 @@ async def scan_all_keys(
return all_keys
except RedisError as e:
return f"Error scanning all keys with pattern '{pattern}': {str(e)}"


@mcp.tool()
async def search_redis_documents(
question: str,
) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
"""Search Redis documentation and knowledge base to learn about Redis concepts and use cases.

This tool exposes updated and curated documentation, and must be invoked every time the user wants to learn more in areas including:

**Common Use Cases:**
- Session Management: User session storage and management
- Caching: Application-level and database query caching
- Rate Limiting: API throttling and request limiting
- Leaderboards: Gaming and ranking systems
- Semantic Search: AI-powered similarity search
- Agentic Workflows: AI agent state and memory management
- RAG (Retrieval-Augmented Generation): Vector storage for AI applications
- Real-time Analytics: Counters, metrics, and time-series data
- Message Queues: Task queues and job processing
- Geospatial: Location-based queries and proximity search

Args:
question: The question about Redis concepts, data structures, features, or use cases

Returns:
Union[List[Dict[str, Any]], Dict[str, Any]]: A list of documentation results from the API, or a dict with an error message.
"""
if not MCP_DOCS_SEARCH_URL:
return {"error": "MCP_DOCS_SEARCH_URL environment variable is not configured"}

if not question.strip():
return {"error": "Question parameter cannot be empty"}

try:
headers = {
"Accept": "application/json",
"User-Agent": f"Redis-MCP-Server/{__version__}",
}
async with aiohttp.ClientSession() as session:
async with session.get(
url=MCP_DOCS_SEARCH_URL, params={"q": question}, headers=headers
) as response:
# Try to parse JSON response
try:
result = await response.json()
return result
except aiohttp.ContentTypeError:
# If not JSON, return text content
text_content = await response.text()
return {"error": f"Non-JSON response: {text_content}"}

except aiohttp.ClientError as e:
return {"error": f"HTTP client error: {str(e)}"}
except Exception as e:
return {"error": f"Unexpected error calling ConvAI API: {str(e)}"}
4 changes: 3 additions & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ def test_server_lists_tools(self, server_process):
"rename",
"scan_keys",
"scan_all_keys",
"search_redis_documents",
"publish",
"subscribe",
"unsubscribe",
Expand Down Expand Up @@ -268,7 +269,7 @@ def test_server_tool_count_and_names(self, server_process):
tool_names = [tool["name"] for tool in tools]

# Expected tool count (based on @mcp.tool() decorators in codebase)
expected_tool_count = 44
expected_tool_count = 45
assert len(tools) == expected_tool_count, (
f"Expected {expected_tool_count} tools, but got {len(tools)}"
)
Expand Down Expand Up @@ -305,6 +306,7 @@ def test_server_tool_count_and_names(self, server_process):
"sadd",
"scan_all_keys",
"scan_keys",
"search_redis_documents",
"set",
"set_vector_in_hash",
"smembers",
Expand Down
3 changes: 2 additions & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_mcp_server_initialization(self, mock_fastmcp):

# Verify FastMCP was called with correct parameters
mock_fastmcp.assert_called_once_with(
"Redis MCP Server", dependencies=["redis", "dotenv", "numpy"]
"Redis MCP Server", dependencies=["redis", "dotenv", "numpy", "aiohttp"]
)

def test_mcp_server_tool_decorator(self):
Expand Down Expand Up @@ -112,6 +112,7 @@ def test_mcp_server_dependencies_list(self, mock_fastmcp):
"redis",
"dotenv",
"numpy",
"aiohttp",
] # Keyword argument

def test_mcp_server_type(self):
Expand Down
136 changes: 136 additions & 0 deletions tests/tools/test_misc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import pytest

import src.tools.misc as misc


@pytest.mark.asyncio
async def test_search_redis_documents_url_not_configured(monkeypatch):
"""Return a clear error if MCP_DOCS_SEARCH_URL is not set for search_redis_documents."""
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "")

result = await misc.search_redis_documents("What is Redis?")

assert isinstance(result, dict)
assert (
result["error"] == "MCP_DOCS_SEARCH_URL environment variable is not configured"
)


@pytest.mark.asyncio
async def test_search_redis_documents_empty_question(monkeypatch):
"""Reject empty/whitespace-only questions for search_redis_documents."""
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")

result = await misc.search_redis_documents(" ")

assert isinstance(result, dict)
assert result["error"] == "Question parameter cannot be empty"


@pytest.mark.asyncio
async def test_search_redis_documents_success_json_response(monkeypatch):
"""Return parsed JSON when the docs API responds with JSON for search_redis_documents."""

class DummyResponse:
async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

async def json(self):
return {"results": [{"title": "Redis Intro"}]}

class DummySession:
async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

def get(self, *_, **__): # pragma: no cover - trivial wrapper
return DummyResponse()

monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
monkeypatch.setattr(misc.aiohttp, "ClientSession", DummySession)

result = await misc.search_redis_documents("What is a Redis stream?")

assert isinstance(result, dict)
assert result["results"][0]["title"] == "Redis Intro"


@pytest.mark.asyncio
async def test_search_redis_documents_non_json_response(monkeypatch):
"""If the response is not JSON, surface the text content in an error from search_redis_documents."""

class DummyContentTypeError(Exception):
pass

class DummyResponse:
async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

async def json(self):
raise DummyContentTypeError("Not JSON")

async def text(self):
return "<html>not json</html>"

class DummySession:
async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

def get(self, *_, **__): # pragma: no cover - trivial wrapper
return DummyResponse()

monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
# Patch aiohttp.ContentTypeError to our dummy so the except block matches
monkeypatch.setattr(misc.aiohttp, "ContentTypeError", DummyContentTypeError)
monkeypatch.setattr(misc.aiohttp, "ClientSession", DummySession)

result = await misc.search_redis_documents("Explain Redis JSON")

assert isinstance(result, dict)
assert "Non-JSON response" in result["error"]
assert "not json" in result["error"]


@pytest.mark.asyncio
async def test_search_redis_documents_http_client_error(monkeypatch):
"""HTTP client errors from search_redis_documents are caught and returned in an error dict."""

class DummyClientError(Exception):
pass

class ErrorResponse:
async def __aenter__(self):
raise DummyClientError("boom")

async def __aexit__(self, exc_type, exc, tb):
return False

class DummySession:
async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

def get(self, *_, **__): # pragma: no cover - trivial wrapper
return ErrorResponse()

monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
monkeypatch.setattr(misc.aiohttp, "ClientError", DummyClientError)
monkeypatch.setattr(misc.aiohttp, "ClientSession", DummySession)

result = await misc.search_redis_documents("What is Redis?")

assert isinstance(result, dict)
assert result["error"] == "HTTP client error: boom"
Loading