Skip to content

Commit 1f35871

Browse files
authored
Merge pull request #87 from redis/feature/add-docs-tool
feat: add search_documents tool for Redis documentation queries
2 parents 424e828 + 230e1a5 commit 1f35871

File tree

9 files changed

+856
-5
lines changed

9 files changed

+856
-5
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ This MCP Server provides tools to manage the data stored in Redis.
7878

7979
Additional tools.
8080

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

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

358+
357359
### EntraID Authentication for Azure Managed Redis
358360

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

671673
> **Tip:** You can prompt Copilot chat to use the Redis MCP tools by including `#redis` in your message.
672674
673-
> **Note:** Starting with [VS Code v1.102](https://code.visualstudio.com/updates/v1_102),
674-
> MCP servers are now stored in a dedicated `mcp.json` file instead of `settings.json`.
675+
> **Note:** Starting with [VS Code v1.102](https://code.visualstudio.com/updates/v1_102),
676+
> MCP servers are now stored in a dedicated `mcp.json` file instead of `settings.json`.
675677
676678
## Testing
677679

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
"dotenv>=0.9.9",
2828
"numpy>=2.2.4",
2929
"click>=8.0.0",
30+
"aiohttp>=3.13.0",
3031
"redis-entraid>=1.0.0",
3132
]
3233

src/common/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@
7676
"resource": os.getenv("REDIS_ENTRAID_RESOURCE", "https://redis.azure.com/"),
7777
}
7878

79+
# ConvAI API configuration
80+
MCP_DOCS_SEARCH_URL = os.getenv(
81+
"MCP_DOCS_SEARCH_URL", "https://redis.io/convai/api/docs/search"
82+
)
83+
7984

8085
def parse_redis_uri(uri: str) -> dict:
8186
"""Parse a Redis URI and return connection parameters."""

src/common/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def load_tools():
1111

1212

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

1616
# Load tools
1717
load_tools()

src/tools/misc.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from typing import Any, Dict, Union, List
2+
import aiohttp
23

34
from redis.exceptions import RedisError
45

56
from src.common.connection import RedisConnectionManager
67
from src.common.server import mcp
8+
from src.common.config import MCP_DOCS_SEARCH_URL
9+
from src.version import __version__
710

811

912
@mcp.tool()
@@ -194,3 +197,59 @@ async def scan_all_keys(
194197
return all_keys
195198
except RedisError as e:
196199
return f"Error scanning all keys with pattern '{pattern}': {str(e)}"
200+
201+
202+
@mcp.tool()
203+
async def search_redis_documents(
204+
question: str,
205+
) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
206+
"""Search Redis documentation and knowledge base to learn about Redis concepts and use cases.
207+
208+
This tool exposes updated and curated documentation, and must be invoked every time the user wants to learn more in areas including:
209+
210+
**Common Use Cases:**
211+
- Session Management: User session storage and management
212+
- Caching: Application-level and database query caching
213+
- Rate Limiting: API throttling and request limiting
214+
- Leaderboards: Gaming and ranking systems
215+
- Semantic Search: AI-powered similarity search
216+
- Agentic Workflows: AI agent state and memory management
217+
- RAG (Retrieval-Augmented Generation): Vector storage for AI applications
218+
- Real-time Analytics: Counters, metrics, and time-series data
219+
- Message Queues: Task queues and job processing
220+
- Geospatial: Location-based queries and proximity search
221+
222+
Args:
223+
question: The question about Redis concepts, data structures, features, or use cases
224+
225+
Returns:
226+
Union[List[Dict[str, Any]], Dict[str, Any]]: A list of documentation results from the API, or a dict with an error message.
227+
"""
228+
if not MCP_DOCS_SEARCH_URL:
229+
return {"error": "MCP_DOCS_SEARCH_URL environment variable is not configured"}
230+
231+
if not question.strip():
232+
return {"error": "Question parameter cannot be empty"}
233+
234+
try:
235+
headers = {
236+
"Accept": "application/json",
237+
"User-Agent": f"Redis-MCP-Server/{__version__}",
238+
}
239+
async with aiohttp.ClientSession() as session:
240+
async with session.get(
241+
url=MCP_DOCS_SEARCH_URL, params={"q": question}, headers=headers
242+
) as response:
243+
# Try to parse JSON response
244+
try:
245+
result = await response.json()
246+
return result
247+
except aiohttp.ContentTypeError:
248+
# If not JSON, return text content
249+
text_content = await response.text()
250+
return {"error": f"Non-JSON response: {text_content}"}
251+
252+
except aiohttp.ClientError as e:
253+
return {"error": f"HTTP client error: {str(e)}"}
254+
except Exception as e:
255+
return {"error": f"Unexpected error calling ConvAI API: {str(e)}"}

tests/test_integration.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ def test_server_lists_tools(self, server_process):
216216
"rename",
217217
"scan_keys",
218218
"scan_all_keys",
219+
"search_redis_documents",
219220
"publish",
220221
"subscribe",
221222
"unsubscribe",
@@ -268,7 +269,7 @@ def test_server_tool_count_and_names(self, server_process):
268269
tool_names = [tool["name"] for tool in tools]
269270

270271
# Expected tool count (based on @mcp.tool() decorators in codebase)
271-
expected_tool_count = 44
272+
expected_tool_count = 45
272273
assert len(tools) == expected_tool_count, (
273274
f"Expected {expected_tool_count} tools, but got {len(tools)}"
274275
)
@@ -305,6 +306,7 @@ def test_server_tool_count_and_names(self, server_process):
305306
"sadd",
306307
"scan_all_keys",
307308
"scan_keys",
309+
"search_redis_documents",
308310
"set",
309311
"set_vector_in_hash",
310312
"smembers",

tests/test_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_mcp_server_initialization(self, mock_fastmcp):
4242

4343
# Verify FastMCP was called with correct parameters
4444
mock_fastmcp.assert_called_once_with(
45-
"Redis MCP Server", dependencies=["redis", "dotenv", "numpy"]
45+
"Redis MCP Server", dependencies=["redis", "dotenv", "numpy", "aiohttp"]
4646
)
4747

4848
def test_mcp_server_tool_decorator(self):
@@ -112,6 +112,7 @@ def test_mcp_server_dependencies_list(self, mock_fastmcp):
112112
"redis",
113113
"dotenv",
114114
"numpy",
115+
"aiohttp",
115116
] # Keyword argument
116117

117118
def test_mcp_server_type(self):

tests/tools/test_misc.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import pytest
2+
3+
import src.tools.misc as misc
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_search_redis_documents_url_not_configured(monkeypatch):
8+
"""Return a clear error if MCP_DOCS_SEARCH_URL is not set for search_redis_documents."""
9+
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "")
10+
11+
result = await misc.search_redis_documents("What is Redis?")
12+
13+
assert isinstance(result, dict)
14+
assert (
15+
result["error"] == "MCP_DOCS_SEARCH_URL environment variable is not configured"
16+
)
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_search_redis_documents_empty_question(monkeypatch):
21+
"""Reject empty/whitespace-only questions for search_redis_documents."""
22+
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
23+
24+
result = await misc.search_redis_documents(" ")
25+
26+
assert isinstance(result, dict)
27+
assert result["error"] == "Question parameter cannot be empty"
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_search_redis_documents_success_json_response(monkeypatch):
32+
"""Return parsed JSON when the docs API responds with JSON for search_redis_documents."""
33+
34+
class DummyResponse:
35+
async def __aenter__(self):
36+
return self
37+
38+
async def __aexit__(self, exc_type, exc, tb):
39+
return False
40+
41+
async def json(self):
42+
return {"results": [{"title": "Redis Intro"}]}
43+
44+
class DummySession:
45+
async def __aenter__(self):
46+
return self
47+
48+
async def __aexit__(self, exc_type, exc, tb):
49+
return False
50+
51+
def get(self, *_, **__): # pragma: no cover - trivial wrapper
52+
return DummyResponse()
53+
54+
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
55+
monkeypatch.setattr(misc.aiohttp, "ClientSession", DummySession)
56+
57+
result = await misc.search_redis_documents("What is a Redis stream?")
58+
59+
assert isinstance(result, dict)
60+
assert result["results"][0]["title"] == "Redis Intro"
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_search_redis_documents_non_json_response(monkeypatch):
65+
"""If the response is not JSON, surface the text content in an error from search_redis_documents."""
66+
67+
class DummyContentTypeError(Exception):
68+
pass
69+
70+
class DummyResponse:
71+
async def __aenter__(self):
72+
return self
73+
74+
async def __aexit__(self, exc_type, exc, tb):
75+
return False
76+
77+
async def json(self):
78+
raise DummyContentTypeError("Not JSON")
79+
80+
async def text(self):
81+
return "<html>not json</html>"
82+
83+
class DummySession:
84+
async def __aenter__(self):
85+
return self
86+
87+
async def __aexit__(self, exc_type, exc, tb):
88+
return False
89+
90+
def get(self, *_, **__): # pragma: no cover - trivial wrapper
91+
return DummyResponse()
92+
93+
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
94+
# Patch aiohttp.ContentTypeError to our dummy so the except block matches
95+
monkeypatch.setattr(misc.aiohttp, "ContentTypeError", DummyContentTypeError)
96+
monkeypatch.setattr(misc.aiohttp, "ClientSession", DummySession)
97+
98+
result = await misc.search_redis_documents("Explain Redis JSON")
99+
100+
assert isinstance(result, dict)
101+
assert "Non-JSON response" in result["error"]
102+
assert "not json" in result["error"]
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_search_redis_documents_http_client_error(monkeypatch):
107+
"""HTTP client errors from search_redis_documents are caught and returned in an error dict."""
108+
109+
class DummyClientError(Exception):
110+
pass
111+
112+
class ErrorResponse:
113+
async def __aenter__(self):
114+
raise DummyClientError("boom")
115+
116+
async def __aexit__(self, exc_type, exc, tb):
117+
return False
118+
119+
class DummySession:
120+
async def __aenter__(self):
121+
return self
122+
123+
async def __aexit__(self, exc_type, exc, tb):
124+
return False
125+
126+
def get(self, *_, **__): # pragma: no cover - trivial wrapper
127+
return ErrorResponse()
128+
129+
monkeypatch.setattr(misc, "MCP_DOCS_SEARCH_URL", "https://example.com/docs")
130+
monkeypatch.setattr(misc.aiohttp, "ClientError", DummyClientError)
131+
monkeypatch.setattr(misc.aiohttp, "ClientSession", DummySession)
132+
133+
result = await misc.search_redis_documents("What is Redis?")
134+
135+
assert isinstance(result, dict)
136+
assert result["error"] == "HTTP client error: boom"

0 commit comments

Comments
 (0)