#
tokens: 48714/50000 42/55 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 1 of 2. Use http://codebase.md/redis/mcp-redis?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .env.example
├── .github
│   ├── dependabot.yml
│   └── workflows
│       ├── ci.yml
│       ├── release.yml
│       └── stale-issues.yml
├── .gitignore
├── Dockerfile
├── examples
│   └── redis_assistant.py
├── fly.toml
├── gemini-extension.json
├── GEMINI.md
├── LICENSE
├── pyproject.toml
├── README.md
├── src
│   ├── __init__.py
│   ├── common
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── connection.py
│   │   ├── entraid_auth.py
│   │   ├── logging_utils.py
│   │   └── server.py
│   ├── main.py
│   ├── tools
│   │   ├── __init__.py
│   │   ├── hash.py
│   │   ├── json.py
│   │   ├── list.py
│   │   ├── misc.py
│   │   ├── pub_sub.py
│   │   ├── redis_query_engine.py
│   │   ├── server_management.py
│   │   ├── set.py
│   │   ├── sorted_set.py
│   │   ├── stream.py
│   │   └── string.py
│   └── version.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_config.py
│   ├── test_connection.py
│   ├── test_entraid_auth.py
│   ├── test_integration.py
│   ├── test_logging_utils.py
│   ├── test_main.py
│   ├── test_server.py
│   └── tools
│       ├── __init__.py
│       ├── test_hash.py
│       ├── test_json.py
│       ├── test_list.py
│       ├── test_pub_sub.py
│       ├── test_redis_query_engine.py
│       ├── test_server_management.py
│       ├── test_set.py
│       ├── test_sorted_set.py
│       ├── test_stream.py
│       └── test_string.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------

```
1 | *
2 | !src
3 | !uv.lock
4 | !pyproject.toml
5 | !README.md
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Python-generated files
 2 | __pycache__/
 3 | *.py[oc]
 4 | build/
 5 | dist/
 6 | wheels/
 7 | *.egg-info
 8 | 
 9 | # Virtual environments
10 | .venv
11 | .env
12 | env/
13 | ENV/
14 | env.bak/
15 | venv.bak/
16 | 
17 | # UV lock file
18 | uv.lock
19 | 
20 | # IDE files
21 | .idea/
22 | .vscode/
23 | *.swp
24 | *.swo
25 | *~
26 | /bandit-report.json
27 | /safety-report.json
28 | /coverage.xml
29 | /.coverage
30 | /htmlcov/
31 | 
```

--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------

```
 1 | REDIS_HOST=your_redis_host
 2 | REDIS_PORT=6379
 3 | REDIS_DB=0
 4 | REDIS_USERNAME=default
 5 | REDIS_PWD=your_password
 6 | REDIS_SSL=False
 7 | REDIS_SSL_CA_PATH=/path/to/ca.pem
 8 | REDIS_SSL_KEYFILE=/path/to/key.pem
 9 | REDIS_SSL_CERTFILE=/path/to/cert.pem
10 | REDIS_SSL_CERT_REQS=required
11 | REDIS_SSL_CA_CERTS=/path/to/ca_certs.pem
12 | REDIS_CLUSTER_MODE=False
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Redis MCP Server
  2 | [![Integration](https://github.com/redis/mcp-redis/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/redis/mcp-redis/actions/workflows/ci.yml)
  3 | [![PyPI - Version](https://img.shields.io/pypi/v/redis-mcp-server)](https://pypi.org/project/redis-mcp-server/)
  4 | [![Python Version](https://img.shields.io/badge/python-3.14%2B-blue&logo=redis)](https://www.python.org/downloads/)
  5 | [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE.txt)
  6 | [![Verified on MseeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/70102150-efe0-4705-9f7d-87980109a279)
  7 | [![Docker Image Version](https://img.shields.io/docker/v/mcp/redis?sort=semver&logo=docker&label=Docker)](https://hub.docker.com/r/mcp/redis)
  8 | [![codecov](https://codecov.io/gh/redis/mcp-redis/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/mcp-redis)
  9 | 
 10 | 
 11 | [![Discord](https://img.shields.io/discord/697882427875393627.svg?style=social&logo=discord)](https://discord.gg/redis)
 12 | [![Twitch](https://img.shields.io/twitch/status/redisinc?style=social)](https://www.twitch.tv/redisinc)
 13 | [![YouTube](https://img.shields.io/youtube/channel/views/UCD78lHSwYqMlyetR0_P4Vig?style=social)](https://www.youtube.com/redisinc)
 14 | [![Twitter](https://img.shields.io/twitter/follow/redisinc?style=social)](https://twitter.com/redisinc)
 15 | [![Stack Exchange questions](https://img.shields.io/stackexchange/stackoverflow/t/mcp-redis?style=social&logo=stackoverflow&label=Stackoverflow)](https://stackoverflow.com/questions/tagged/mcp-redis)
 16 | 
 17 | ## Overview
 18 | The Redis MCP Server is a **natural language interface** designed for agentic applications to efficiently manage and search data in Redis. It integrates seamlessly with **MCP (Model Content Protocol) clients**, enabling AI-driven workflows to interact with structured and unstructured data in Redis. Using this MCP Server, you can ask questions like:
 19 | 
 20 | - "Store the entire conversation in a stream"
 21 | - "Cache this item"
 22 | - "Store the session with an expiration time"
 23 | - "Index and search this vector"
 24 | 
 25 | ## Table of Contents
 26 | - [Overview](#overview)
 27 | - [Features](#features)
 28 | - [Tools](#tools)
 29 | - [Installation](#installation)
 30 |   - [From PyPI (recommended)](#from-pypi-recommended)
 31 |   - [Testing the PyPI package](#testing-the-pypi-package)
 32 |   - [From GitHub](#from-github)
 33 |   - [Development Installation](#development-installation)
 34 |   - [With Docker](#with-docker)
 35 | - [Configuration](#configuration)
 36 |   - [Redis ACL](#redis-acl)
 37 |   - [Configuration via command line arguments](#configuration-via-command-line-arguments)
 38 |   - [Configuration via Environment Variables](#configuration-via-environment-variables)
 39 |   - [EntraID Authentication for Azure Managed Redis](#entraid-authentication-for-azure-managed-redis)
 40 |   - [Logging](#logging)
 41 | - [Integrations](#integrations)
 42 |   - [OpenAI Agents SDK](#openai-agents-sdk)
 43 |   - [Augment](#augment)
 44 |   - [Claude Desktop](#claude-desktop)
 45 |   - [VS Code with GitHub Copilot](#vs-code-with-github-copilot)
 46 | - [Testing](#testing)
 47 | - [Example Use Cases](#example-use-cases)
 48 | - [Contributing](#contributing)
 49 | - [License](#license)
 50 | - [Badges](#badges)
 51 | - [Contact](#contact)
 52 | 
 53 | 
 54 | ## Features
 55 | - **Natural Language Queries**: Enables AI agents to query and update Redis using natural language.
 56 | - **Seamless MCP Integration**: Works with any **MCP client** for smooth communication.
 57 | - **Full Redis Support**: Handles **hashes, lists, sets, sorted sets, streams**, and more.
 58 | - **Search & Filtering**: Supports efficient data retrieval and searching in Redis.
 59 | - **Scalable & Lightweight**: Designed for **high-performance** data operations.
 60 | - **EntraID Authentication**: Native support for Azure Active Directory authentication with Azure Managed Redis.
 61 | - The Redis MCP Server supports the `stdio` [transport](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio). Support to the `stremable-http` transport will be added in the future.
 62 | 
 63 | ## Tools
 64 | 
 65 | This MCP Server provides tools to manage the data stored in Redis.
 66 | 
 67 | - `string` tools to set, get strings with expiration. Useful for storing simple configuration values, session data, or caching responses.
 68 | - `hash` tools to store field-value pairs within a single key. The hash can store vector embeddings. Useful for representing objects with multiple attributes, user profiles, or product information where fields can be accessed individually.
 69 | - `list` tools with common operations to append and pop items. Useful for queues, message brokers, or maintaining a list of most recent actions.
 70 | - `set` tools to add, remove and list set members. Useful for tracking unique values like user IDs or tags, and for performing set operations like intersection.
 71 | - `sorted set` tools to manage data for e.g. leaderboards, priority queues, or time-based analytics with score-based ordering.
 72 | - `pub/sub` functionality to publish messages to channels and subscribe to receive them. Useful for real-time notifications, chat applications, or distributing updates to multiple clients.
 73 | - `streams` tools to add, read, and delete from data streams. Useful for event sourcing, activity feeds, or sensor data logging with consumer groups support.
 74 | - `JSON` tools to store, retrieve, and manipulate JSON documents in Redis. Useful for complex nested data structures, document databases, or configuration management with path-based access.
 75 | 
 76 | Additional tools.
 77 | 
 78 | - `query engine` tools to manage vector indexes and perform vector search
 79 | - `server management` tool to retrieve information about the database
 80 | 
 81 | ## Installation
 82 | 
 83 | The Redis MCP Server is available as a PyPI package and as direct installation from the GitHub repository.
 84 | 
 85 | ### From PyPI (recommended)
 86 | Configuring the latest Redis MCP Server version from PyPI, as an example, can be done importing the following JSON configuration in the desired framework or tool.
 87 | The `uvx` command will download the server on the fly (if not cached already), create a temporary environment, and then run it.
 88 | 
 89 | ```commandline
 90 | {
 91 |   "mcpServers": {
 92 |     "RedisMCPServer": {
 93 |       "command": "uvx",
 94 |       "args": [
 95 |         "--from",
 96 |         "redis-mcp-server@latest",
 97 |         "redis-mcp-server",
 98 |         "--url",
 99 |         "\"redis://localhost:6379/0\""
100 |       ]
101 |     }
102 |   }
103 | }
104 | ```
105 | 
106 | #### URL specification
107 | 
108 | The format to specify the `--url` argument follows the [redis](https://www.iana.org/assignments/uri-schemes/prov/redis) and [rediss](https://www.iana.org/assignments/uri-schemes/prov/rediss) schemes:
109 | 
110 | ```commandline
111 | redis://user:secret@localhost:6379/0?foo=bar&qux=baz
112 | ```
113 | 
114 | As an example, you can easily connect to a localhost server with:
115 | 
116 | ```commandline
117 | redis://localhost:6379/0
118 | ```
119 | 
120 | Where `0` is the [logical database](https://redis.io/docs/latest/commands/select/) you'd like to connect to.
121 | 
122 | For an encrypted connection to the database (e.g., connecting to a [Redis Cloud](https://redis.io/cloud/) database), you'd use the `rediss` scheme.
123 | 
124 | ```commandline
125 | rediss://user:secret@localhost:6379/0?foo=bar&qux=baz
126 | ```
127 | 
128 | To verify the server's identity, specify `ssl_ca_certs`.
129 | 
130 | ```commandline
131 | rediss://user:secret@hostname:port?ssl_cert_reqs=required&ssl_ca_certs=path_to_the_certificate
132 | ```
133 | 
134 | For an unverified connection, set `ssl_cert_reqs` to `none`
135 | 
136 | ```commandline
137 | rediss://user:secret@hostname:port?ssl_cert_reqs=none
138 | ```
139 | 
140 | Configure your connection using the available options in the section "Available CLI Options".
141 | 
142 | ### Testing the PyPI package
143 | 
144 | You can install the package as follows:
145 | 
146 | ```sh
147 | pip install redis-mcp-server
148 | ```
149 | 
150 | And start it using `uv` the package in your environment.
151 | 
152 | ```sh
153 | uv python install 3.14
154 | uv sync
155 | uv run redis-mcp-server --url redis://localhost:6379/0
156 | ```
157 | 
158 | However, starting the MCP Server is most useful when delegate to the framework or tool where this MCP Server is configured.
159 | 
160 | ### From GitHub
161 | 
162 | You can configure the desired Redis MCP Server version with `uvx`, which allows you to run it directly from GitHub (from a branch, or use a tagged release).
163 | 
164 | > It is recommended to use a tagged release, the `main` branch is under active development and may contain breaking changes.
165 | 
166 | As an example, you can execute the following command to run the `0.2.0` release:
167 | 
168 | ```commandline
169 | uvx --from git+https://github.com/redis/[email protected] redis-mcp-server --url redis://localhost:6379/0
170 | ```
171 | 
172 | Check the release notes for the latest version in the [Releases](https://github.com/redis/mcp-redis/releases) section.
173 | Additional examples are provided below.
174 | 
175 | ```sh
176 | # Run with Redis URI
177 | uvx --from git+https://github.com/redis/mcp-redis.git redis-mcp-server --url redis://localhost:6379/0
178 | 
179 | # Run with Redis URI and SSL
180 | uvx --from git+https://github.com/redis/mcp-redis.git redis-mcp-server --url "rediss://<USERNAME>:<PASSWORD>@<HOST>:<PORT>?ssl_cert_reqs=required&ssl_ca_certs=<PATH_TO_CERT>"
181 | 
182 | # Run with individual parameters
183 | uvx --from git+https://github.com/redis/mcp-redis.git redis-mcp-server --host localhost --port 6379 --password mypassword
184 | 
185 | # See all options
186 | uvx --from git+https://github.com/redis/mcp-redis.git redis-mcp-server --help
187 | ```
188 | 
189 | ### Development Installation
190 | 
191 | For development or if you prefer to clone the repository:
192 | 
193 | ```sh
194 | # Clone the repository
195 | git clone https://github.com/redis/mcp-redis.git
196 | cd mcp-redis
197 | 
198 | # Install dependencies using uv
199 | uv venv
200 | source .venv/bin/activate
201 | uv sync
202 | 
203 | # Run with CLI interface
204 | uv run redis-mcp-server --help
205 | 
206 | # Or run the main file directly (uses environment variables)
207 | uv run src/main.py
208 | ```
209 | 
210 | Once you cloned the repository, installed the dependencies and verified you can run the server, you can configure Claude Desktop or any other MCP Client to use this MCP Server running the main file directly (it uses environment variables). This is usually preferred for development.
211 | The following example is for Claude Desktop, but the same applies to any other MCP Client.
212 | 
213 | 1. Specify your Redis credentials and TLS configuration
214 | 2. Retrieve your `uv` command full path (e.g. `which uv`)
215 | 3. Edit the `claude_desktop_config.json` configuration file
216 |    - on a MacOS, at `~/Library/Application\ Support/Claude/`
217 | 
218 | ```json
219 | {
220 |     "mcpServers": {
221 |         "redis": {
222 |             "command": "<full_path_uv_command>",
223 |             "args": [
224 |                 "--directory",
225 |                 "<your_mcp_server_directory>",
226 |                 "run",
227 |                 "src/main.py"
228 |             ],
229 |             "env": {
230 |                 "REDIS_HOST": "<your_redis_database_hostname>",
231 |                 "REDIS_PORT": "<your_redis_database_port>",
232 |                 "REDIS_PWD": "<your_redis_database_password>",
233 |                 "REDIS_SSL": True|False,
234 |                 "REDIS_SSL_CA_PATH": "<your_redis_ca_path>",
235 |                 "REDIS_CLUSTER_MODE": True|False
236 |             }
237 |         }
238 |     }
239 | }
240 | ```
241 | 
242 | You can troubleshoot problems by tailing the log file.
243 | 
244 | ```commandline
245 | tail -f ~/Library/Logs/Claude/mcp-server-redis.log
246 | ```
247 | 
248 | ### With Docker
249 | 
250 | You can use a dockerized deployment of this server. You can either build your own image or use the official [Redis MCP Docker](https://hub.docker.com/r/mcp/redis) image.
251 | 
252 | If you'd like to build your own image, the Redis MCP Server provides a Dockerfile. Build this server's image with:
253 | 
254 | ```commandline
255 | docker build -t mcp-redis .
256 | ```
257 | 
258 | Finally, configure the client to create the container at start-up. An example for Claude Desktop is provided below. Edit the `claude_desktop_config.json` and add:
259 | 
260 | ```json
261 | {
262 |   "mcpServers": {
263 |     "redis": {
264 |       "command": "docker",
265 |       "args": ["run",
266 |                 "--rm",
267 |                 "--name",
268 |                 "redis-mcp-server",
269 |                 "-i",
270 |                 "-e", "REDIS_HOST=<redis_hostname>",
271 |                 "-e", "REDIS_PORT=<redis_port>",
272 |                 "-e", "REDIS_USERNAME=<redis_username>",
273 |                 "-e", "REDIS_PWD=<redis_password>",
274 |                 "mcp-redis"]
275 |     }
276 |   }
277 | }
278 | ```
279 | 
280 | To use the official [Redis MCP Docker](https://hub.docker.com/r/mcp/redis) image, just replace your image name (`mcp-redis` in the example above) with `mcp/redis`.
281 | 
282 | ## Configuration
283 | 
284 | The Redis MCP Server can be configured in two ways: via command line arguments or via environment variables.
285 | The precedence is: command line arguments > environment variables > default values.
286 | 
287 | ### Redis ACL
288 | 
289 | You can configure Redis ACL to restrict the access to the Redis database. For example, to create a read-only user:
290 | 
291 | ```
292 | 127.0.0.1:6379> ACL SETUSER readonlyuser on >mypassword ~* +@read -@write
293 | ```
294 | 
295 | Configure the user via command line arguments or environment variables.
296 | 
297 | ### Configuration via command line arguments
298 | 
299 | When using the CLI interface, you can configure the server with command line arguments:
300 | 
301 | ```sh
302 | # Basic Redis connection
303 | uvx --from redis-mcp-server@latest redis-mcp-server \
304 |   --host localhost \
305 |   --port 6379 \
306 |   --password mypassword
307 | 
308 | # Using Redis URI (simpler)
309 | uvx --from redis-mcp-server@latest redis-mcp-server \
310 |   --url redis://user:pass@localhost:6379/0
311 | 
312 | # SSL connection
313 | uvx --from redis-mcp-server@latest redis-mcp-server \
314 |   --url rediss://user:[email protected]:6379/0
315 | 
316 | # See all available options
317 | uvx --from redis-mcp-server@latest redis-mcp-server --help
318 | ```
319 | 
320 | **Available CLI Options:**
321 | - `--url` - Redis connection URI (redis://user:pass@host:port/db)
322 | - `--host` - Redis hostname (default: 127.0.0.1)
323 | - `--port` - Redis port (default: 6379)
324 | - `--db` - Redis database number (default: 0)
325 | - `--username` - Redis username
326 | - `--password` - Redis password
327 | - `--ssl` - Enable SSL connection
328 | - `--ssl-ca-path` - Path to CA certificate file
329 | - `--ssl-keyfile` - Path to SSL key file
330 | - `--ssl-certfile` - Path to SSL certificate file
331 | - `--ssl-cert-reqs` - SSL certificate requirements (default: required)
332 | - `--ssl-ca-certs` - Path to CA certificates file
333 | - `--cluster-mode` - Enable Redis cluster mode
334 | 
335 | ### Configuration via Environment Variables
336 | 
337 | If desired, you can use environment variables. Defaults are provided for all variables.
338 | 
339 | | Name                 | Description                                               | Default Value |
340 | |----------------------|-----------------------------------------------------------|---------------|
341 | | `REDIS_HOST`         | Redis IP or hostname                                      | `"127.0.0.1"` |
342 | | `REDIS_PORT`         | Redis port                                                | `6379`        |
343 | | `REDIS_DB`           | Database                                                  | 0             |
344 | | `REDIS_USERNAME`     | Default database username                                 | `"default"`   |
345 | | `REDIS_PWD`          | Default database password                                 | ""            |
346 | | `REDIS_SSL`          | Enables or disables SSL/TLS                               | `False`       |
347 | | `REDIS_SSL_CA_PATH`  | CA certificate for verifying server                       | None          |
348 | | `REDIS_SSL_KEYFILE`  | Client's private key file for client authentication       | None          |
349 | | `REDIS_SSL_CERTFILE` | Client's certificate file for client authentication       | None          |
350 | | `REDIS_SSL_CERT_REQS`| Whether the client should verify the server's certificate | `"required"`  |
351 | | `REDIS_SSL_CA_CERTS` | Path to the trusted CA certificates file                  | None          |
352 | | `REDIS_CLUSTER_MODE` | Enable Redis Cluster mode                                 | `False`       |
353 | 
354 | ### EntraID Authentication for Azure Managed Redis
355 | 
356 | The Redis MCP Server supports **EntraID (Azure Active Directory) authentication** for Azure Managed Redis, enabling OAuth-based authentication with automatic token management.
357 | 
358 | #### Authentication Providers
359 | 
360 | **Service Principal Authentication** - Application-based authentication using client credentials:
361 | ```bash
362 | export REDIS_ENTRAID_AUTH_FLOW=service_principal
363 | export REDIS_ENTRAID_CLIENT_ID=your-client-id
364 | export REDIS_ENTRAID_CLIENT_SECRET=your-client-secret
365 | export REDIS_ENTRAID_TENANT_ID=your-tenant-id
366 | ```
367 | 
368 | **Managed Identity Authentication** - For Azure-hosted applications:
369 | ```bash
370 | # System-assigned managed identity
371 | export REDIS_ENTRAID_AUTH_FLOW=managed_identity
372 | export REDIS_ENTRAID_IDENTITY_TYPE=system_assigned
373 | 
374 | # User-assigned managed identity
375 | export REDIS_ENTRAID_AUTH_FLOW=managed_identity
376 | export REDIS_ENTRAID_IDENTITY_TYPE=user_assigned
377 | export REDIS_ENTRAID_USER_ASSIGNED_CLIENT_ID=your-identity-client-id
378 | ```
379 | 
380 | **Default Azure Credential** - Automatic credential discovery (recommended for development):
381 | ```bash
382 | export REDIS_ENTRAID_AUTH_FLOW=default_credential
383 | export REDIS_ENTRAID_SCOPES=https://redis.azure.com/.default
384 | ```
385 | 
386 | #### EntraID Configuration Variables
387 | 
388 | | Name                                    | Description                                               | Default Value                        |
389 | |-----------------------------------------|-----------------------------------------------------------|--------------------------------------|
390 | | `REDIS_ENTRAID_AUTH_FLOW`               | Authentication flow type                                  | None (EntraID disabled)              |
391 | | `REDIS_ENTRAID_CLIENT_ID`               | Service Principal client ID                               | None                                 |
392 | | `REDIS_ENTRAID_CLIENT_SECRET`           | Service Principal client secret                           | None                                 |
393 | | `REDIS_ENTRAID_TENANT_ID`               | Azure tenant ID                                           | None                                 |
394 | | `REDIS_ENTRAID_IDENTITY_TYPE`           | Managed identity type                                     | `"system_assigned"`                  |
395 | | `REDIS_ENTRAID_USER_ASSIGNED_CLIENT_ID` | User-assigned managed identity client ID                  | None                                 |
396 | | `REDIS_ENTRAID_SCOPES`                  | OAuth scopes for Default Azure Credential                | `"https://redis.azure.com/.default"` |
397 | | `REDIS_ENTRAID_RESOURCE`                | Azure Redis resource identifier                          | `"https://redis.azure.com/"`         |
398 | 
399 | #### Key Features
400 | 
401 | - **Automatic token renewal** - Background token refresh with no manual intervention
402 | - **Graceful fallback** - Falls back to standard Redis authentication when EntraID not configured
403 | - **Multiple auth flows** - Supports Service Principal, Managed Identity, and Default Azure Credential
404 | - **Enterprise ready** - Designed for Azure Managed Redis with centralized identity management
405 | 
406 | #### Example Configuration
407 | 
408 | For **local development** with Azure CLI:
409 | ```bash
410 | # Login with Azure CLI
411 | az login
412 | 
413 | # Configure MCP server
414 | export REDIS_ENTRAID_AUTH_FLOW=default_credential
415 | export REDIS_URL=redis://your-azure-redis.redis.cache.windows.net:6379
416 | ```
417 | 
418 | For **production** with Service Principal:
419 | ```bash
420 | export REDIS_ENTRAID_AUTH_FLOW=service_principal
421 | export REDIS_ENTRAID_CLIENT_ID=your-app-client-id
422 | export REDIS_ENTRAID_CLIENT_SECRET=your-app-secret
423 | export REDIS_ENTRAID_TENANT_ID=your-tenant-id
424 | export REDIS_URL=redis://your-azure-redis.redis.cache.windows.net:6379
425 | ```
426 | 
427 | For **Azure-hosted applications** with Managed Identity:
428 | ```bash
429 | export REDIS_ENTRAID_AUTH_FLOW=managed_identity
430 | export REDIS_ENTRAID_IDENTITY_TYPE=system_assigned
431 | export REDIS_URL=redis://your-azure-redis.redis.cache.windows.net:6379
432 | ```
433 | 
434 | There are several ways to set environment variables:
435 | 
436 | 1. **Using a `.env` File**:
437 | Place a `.env` file in your project directory with key-value pairs for each environment variable. Tools like `python-dotenv`, `pipenv`, and `uv` can automatically load these variables when running your application. This is a convenient and secure way to manage configuration, as it keeps sensitive data out of your shell history and version control (if `.env` is in `.gitignore`).
438 | For example, create a `.env` file with the following content from the `.env.example` file provided in the repository:
439 | 
440 | ```bash
441 | cp .env.example .env
442 | ```
443 | 
444 | Then edit the `.env` file to set your Redis configuration:
445 | 
446 | OR,
447 | 
448 | 2. **Setting Variables in the Shell**:
449 | You can export environment variables directly in your shell before running your application. For example:
450 | 
451 | ```sh
452 | export REDIS_HOST=your_redis_host
453 | export REDIS_PORT=6379
454 | # Other variables will be set similarly...
455 | ```
456 | 
457 | This method is useful for temporary overrides or quick testing.
458 | 
459 | 
460 | ### Logging
461 | 
462 | The server uses Python's standard logging and is configured at startup. By default it logs at WARNING and above. You can change verbosity with the `MCP_REDIS_LOG_LEVEL` environment variable.
463 | 
464 | - Accepted values (case-insensitive): `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, `NOTSET`
465 | - Aliases supported: `WARN` → `WARNING`, `FATAL` → `CRITICAL`
466 | - Numeric values are also accepted, including signed (e.g., `"10"`, `"+20"`)
467 | - Default when unset or unrecognized: `WARNING`
468 | 
469 | Handler behavior
470 | - If the host (e.g., `uv`, VS Code, pytest) already installed console handlers, the server will NOT add its own; it only lowers overly-restrictive handler thresholds so your chosen level is not filtered out. It will never raise a handler's threshold.
471 | - If no handlers are present, the server adds a single stderr StreamHandler with a simple format.
472 | 
473 | Examples
474 | ```bash
475 | # See normal lifecycle messages
476 | MCP_REDIS_LOG_LEVEL=INFO uv run src/main.py
477 | 
478 | # Very verbose for debugging
479 | MCP_REDIS_LOG_LEVEL=DEBUG uvx --from redis-mcp-server@latest redis-mcp-server --url redis://localhost:6379/0
480 | ```
481 | 
482 | In MCP client configs that support env, add it alongside your Redis settings. For example:
483 | ```json
484 | {
485 |   "mcpServers": {
486 |     "redis": {
487 |       "command": "uvx",
488 |       "args": ["--from", "redis-mcp-server@latest", "redis-mcp-server", "--url", "redis://localhost:6379/0"],
489 |       "env": {
490 |         "REDIS_HOST": "localhost",
491 |         "REDIS_PORT": "6379",
492 |         "MCP_REDIS_LOG_LEVEL": "INFO"
493 |       }
494 |     }
495 |   }
496 | }
497 | ```
498 | 
499 | 
500 | ## Integrations
501 | 
502 | Integrating this MCP Server to development frameworks like OpenAI Agents SDK, or with tools like Claude Desktop, VS Code, or Augment is described in the following sections.
503 | 
504 | ### OpenAI Agents SDK
505 | 
506 | Integrate this MCP Server with the OpenAI Agents SDK. Read the [documents](https://openai.github.io/openai-agents-python/mcp/) to learn more about the integration of the SDK with MCP.
507 | 
508 | Install the Python SDK.
509 | 
510 | ```commandline
511 | pip install openai-agents
512 | ```
513 | 
514 | Configure the OpenAI token:
515 | 
516 | ```commandline
517 | export OPENAI_API_KEY="<openai_token>"
518 | ```
519 | 
520 | And run the [application](./examples/redis_assistant.py).
521 | 
522 | ```commandline
523 | python3.14 redis_assistant.py
524 | ```
525 | 
526 | You can troubleshoot your agent workflows using the [OpenAI dashboard](https://platform.openai.com/traces/).
527 | 
528 | ### Augment
529 | 
530 | The preferred way of configuring the Redis MCP Server in Augment is to use the [Easy MCP](https://docs.augmentcode.com/setup-augment/mcp#redis) feature.
531 | 
532 | You can also configure the Redis MCP Server in Augment manually by importing the server via JSON:
533 | 
534 | ```json
535 | {
536 |   "mcpServers": {
537 |     "Redis MCP Server": {
538 |       "command": "uvx",
539 |       "args": [
540 |         "--from",
541 |         "redis-mcp-server@latest",
542 |         "redis-mcp-server",
543 |         "--url",
544 |         "redis://localhost:6379/0"
545 |       ]
546 |     }
547 |   }
548 | }
549 | ```
550 | 
551 | ### Claude Desktop
552 | 
553 | The simplest way to configure MCP clients is using `uvx`. Add the following JSON to your `claude_desktop_config.json`, remember to provide the full path to `uvx`.
554 | 
555 | **Basic Redis connection:**
556 | ```json
557 | {
558 |   "mcpServers": {
559 |     "redis-mcp-server": {
560 |         "type": "stdio",
561 |         "command": "/Users/mortensi/.local/bin/uvx",
562 |         "args": [
563 |             "--from", "redis-mcp-server@latest",
564 |             "redis-mcp-server",
565 |             "--url", "redis://localhost:6379/0"
566 |         ]
567 |     }
568 |   }
569 | }
570 | ```
571 | 
572 | **Azure Managed Redis with EntraID authentication:**
573 | ```json
574 | {
575 |   "mcpServers": {
576 |     "redis-mcp-server": {
577 |         "type": "stdio",
578 |         "command": "/Users/mortensi/.local/bin/uvx",
579 |         "args": [
580 |             "--from", "redis-mcp-server@latest",
581 |             "redis-mcp-server",
582 |             "--url", "redis://your-azure-redis.redis.cache.windows.net:6379"
583 |         ],
584 |         "env": {
585 |             "REDIS_ENTRAID_AUTH_FLOW": "default_credential",
586 |             "REDIS_ENTRAID_SCOPES": "https://redis.azure.com/.default"
587 |         }
588 |     }
589 |   }
590 | }
591 | ```
592 | 
593 | ### VS Code with GitHub Copilot
594 | 
595 | To use the Redis MCP Server with VS Code, you must nable the [agent mode](https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode) tools. Add the following to your `settings.json`:
596 | 
597 | ```json
598 | {
599 |   "chat.agent.enabled": true
600 | }
601 | ```
602 | 
603 | You can start the GitHub desired version of the Redis MCP server using `uvx` by adding the following JSON to your `mcp.json` file:
604 | 
605 | ```json
606 | "servers": {
607 |   "redis": {
608 |     "type": "stdio",
609 |     "command": "uvx",
610 |     "args": [
611 |       "--from", "redis-mcp-server@latest",
612 |       "redis-mcp-server",
613 |       "--url", "redis://localhost:6379/0"
614 |     ]
615 |   },
616 | }
617 | ```
618 | 
619 | #### Suppressing uvx Installation Messages
620 | 
621 | If you want to suppress uvx installation messages that may appear as warnings in MCP client logs, use the `-qq` flag:
622 | 
623 | ```json
624 | "servers": {
625 |   "redis": {
626 |     "type": "stdio",
627 |     "command": "uvx",
628 |     "args": [
629 |       "-qq",
630 |       "--from", "redis-mcp-server@latest",
631 |       "redis-mcp-server",
632 |       "--url", "redis://localhost:6379/0"
633 |     ]
634 |   },
635 | }
636 | ```
637 | 
638 | The `-qq` flag enables silent mode, which suppresses "Installed X packages" messages that uvx writes to stderr during package installation.
639 | 
640 | Alternatively, you can start the server using `uv` and configure your `mcp.json`. This is usually desired for development.
641 | 
642 | ```json
643 | // mcp.json
644 | {
645 |   "servers": {
646 |     "redis": {
647 |       "type": "stdio",
648 |       "command": "<full_path_uv_command>",
649 |       "args": [
650 |         "--directory",
651 |         "<your_mcp_server_directory>",
652 |         "run",
653 |         "src/main.py"
654 |       ],
655 |       "env": {
656 |         "REDIS_HOST": "<your_redis_database_hostname>",
657 |         "REDIS_PORT": "<your_redis_database_port>",
658 |         "REDIS_USERNAME": "<your_redis_database_username>",
659 |         "REDIS_PWD": "<your_redis_database_password>",
660 |       }
661 |     }
662 |   }
663 | }
664 | ```
665 | 
666 | For more information, see the [VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers).
667 | 
668 | > **Tip:** You can prompt Copilot chat to use the Redis MCP tools by including `#redis` in your message.
669 | 
670 | > **Note:** Starting with [VS Code v1.102](https://code.visualstudio.com/updates/v1_102),  
671 | > MCP servers are now stored in a dedicated `mcp.json` file instead of `settings.json`. 
672 | 
673 | ## Testing
674 | 
675 | You can use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for visual debugging of this MCP Server.
676 | 
677 | ```sh
678 | npx @modelcontextprotocol/inspector uv run src/main.py
679 | ```
680 | 
681 | ## Example Use Cases
682 | - **AI Assistants**: Enable LLMs to fetch, store, and process data in Redis.
683 | - **Chatbots & Virtual Agents**: Retrieve session data, manage queues, and personalize responses.
684 | - **Data Search & Analytics**: Query Redis for **real-time insights and fast lookups**.
685 | - **Event Processing**: Manage event streams with **Redis Streams**.
686 | 
687 | ## Contributing
688 | 1. Fork the repo
689 | 2. Create a new branch (`feature-branch`)
690 | 3. Commit your changes
691 | 4. Push to your branch and submit a PR!
692 | 
693 | ## License
694 | This project is licensed under the **MIT License**.
695 | 
696 | ## Badges
697 | 
698 | <a href="https://glama.ai/mcp/servers/@redis/mcp-redis">
699 |   <img width="380" height="200" src="https://glama.ai/mcp/servers/@redis/mcp-redis/badge" alt="Redis Server MCP server" />
700 | </a>
701 | 
702 | ## Contact
703 | For questions or support, reach out via [GitHub Issues](https://github.com/redis/mcp-redis/issues).
704 | 
705 | Alternatively, you can join the [Redis Discord server](https://discord.gg/redis) and ask in the `#redis-mcp-server` channel.
706 | 
```

--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------

```python
1 | 
```

--------------------------------------------------------------------------------
/src/common/__init__.py:
--------------------------------------------------------------------------------

```python
1 | 
```

--------------------------------------------------------------------------------
/src/tools/__init__.py:
--------------------------------------------------------------------------------

```python
1 | 
```

--------------------------------------------------------------------------------
/src/version.py:
--------------------------------------------------------------------------------

```python
1 | __version__ = "0.3.5"
2 | 
```

--------------------------------------------------------------------------------
/tests/tools/__init__.py:
--------------------------------------------------------------------------------

```python
1 | # Tests for tools package
2 | 
```

--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------

```python
1 | # Tests package for Redis MCP Server
2 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | FROM python:3.14-slim
 2 | RUN pip install --upgrade uv
 3 | 
 4 | WORKDIR /app
 5 | COPY . /app
 6 | RUN --mount=type=cache,target=/root/.cache/uv \
 7 |     uv sync --locked
 8 | 
 9 | CMD ["uv", "run", "python", "src/main.py"]
10 | 
```

--------------------------------------------------------------------------------
/gemini-extension.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "redis",
 3 |   "version": "0.1.0",
 4 |   "description": "Manage and search data in Redis efficiently within Gemini CLI.",
 5 |   "mcpServers": {
 6 |     "redis": {
 7 |       "command": "uvx",
 8 |       "args": [
 9 |         "--from",
10 |         "redis-mcp-server@latest",
11 |         "redis-mcp-server",
12 |         "--url", "${REDIS_URL}"
13 |       ]
14 |     }
15 |   }
16 | }
17 | 
```

--------------------------------------------------------------------------------
/src/common/server.py:
--------------------------------------------------------------------------------

```python
 1 | import importlib
 2 | import pkgutil
 3 | from mcp.server.fastmcp import FastMCP
 4 | 
 5 | 
 6 | def load_tools():
 7 |     import src.tools as tools_pkg
 8 | 
 9 |     for _, module_name, _ in pkgutil.iter_modules(tools_pkg.__path__):
10 |         importlib.import_module(f"src.tools.{module_name}")
11 | 
12 | 
13 | # Initialize FastMCP server
14 | mcp = FastMCP("Redis MCP Server", dependencies=["redis", "dotenv", "numpy"])
15 | 
16 | # Load tools
17 | load_tools()
18 | 
```

--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------

```toml
 1 | # fly.toml app configuration file generated for mcp-redis-7s on 2025-07-24T07:40:43Z
 2 | #
 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
 4 | #
 5 | 
 6 | app = 'mcp-redis-7s'
 7 | primary_region = 'sin'
 8 | 
 9 | [build]
10 | 
11 | [http_service]
12 |   internal_port = 8080
13 |   force_https = true
14 |   auto_stop_machines = 'stop'
15 |   auto_start_machines = true
16 |   min_machines_running = 0
17 |   processes = ['app']
18 | 
19 | [[vm]]
20 |   memory = '1gb'
21 |   cpu_kind = 'shared'
22 |   cpus = 1
23 |   memory_mb = 1024
24 | 
```

--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------

```yaml
 1 | version: 2
 2 | updates:
 3 |   # Enable version updates for Python dependencies
 4 |   - package-ecosystem: "pip"
 5 |     directory: "/"
 6 |     schedule:
 7 |       interval: "weekly"
 8 |       day: "monday"
 9 |       time: "09:00"
10 |     open-pull-requests-limit: 10
11 |     commit-message:
12 |       prefix: "deps"
13 |       include: "scope"
14 |     labels:
15 |       - "dependencies"
16 | 
17 |   # Enable version updates for GitHub Actions
18 |   - package-ecosystem: "github-actions"
19 |     directory: "/"
20 |     schedule:
21 |       interval: "weekly"
22 |       day: "monday"
23 |       time: "09:00"
24 |     open-pull-requests-limit: 5
25 |     commit-message:
26 |       prefix: "ci"
27 |       include: "scope"
28 |     labels:
29 |       - "dependencies"
30 | 
31 |   # Enable version updates for Docker
32 |   - package-ecosystem: "docker"
33 |     directory: "/"
34 |     schedule:
35 |       interval: "weekly"
36 |       day: "monday"
37 |       time: "09:00"
38 |     open-pull-requests-limit: 5
39 |     commit-message:
40 |       prefix: "docker"
41 |       include: "scope"
42 |     labels:
43 |       - "dependencies"
44 | 
```

--------------------------------------------------------------------------------
/src/tools/server_management.py:
--------------------------------------------------------------------------------

```python
 1 | from redis.exceptions import RedisError
 2 | 
 3 | from src.common.connection import RedisConnectionManager
 4 | from src.common.server import mcp
 5 | 
 6 | 
 7 | @mcp.tool()
 8 | async def dbsize() -> int:
 9 |     """Get the number of keys stored in the Redis database"""
10 |     try:
11 |         r = RedisConnectionManager.get_connection()
12 |         return r.dbsize()
13 |     except RedisError as e:
14 |         return f"Error getting database size: {str(e)}"
15 | 
16 | 
17 | @mcp.tool()
18 | async def info(section: str = "default") -> dict:
19 |     """Get Redis server information and statistics.
20 | 
21 |     Args:
22 |         section: The section of the info command (default, memory, cpu, etc.).
23 | 
24 |     Returns:
25 |         A dictionary of server information or an error message.
26 |     """
27 |     try:
28 |         r = RedisConnectionManager.get_connection()
29 |         info = r.info(section)
30 |         return info
31 |     except RedisError as e:
32 |         return f"Error retrieving Redis info: {str(e)}"
33 | 
34 | 
35 | @mcp.tool()
36 | async def client_list() -> list:
37 |     """Get a list of connected clients to the Redis server."""
38 |     try:
39 |         r = RedisConnectionManager.get_connection()
40 |         clients = r.client_list()
41 |         return clients
42 |     except RedisError as e:
43 |         return f"Error retrieving client list: {str(e)}"
44 | 
```

--------------------------------------------------------------------------------
/src/tools/pub_sub.py:
--------------------------------------------------------------------------------

```python
 1 | from redis.exceptions import RedisError
 2 | 
 3 | from src.common.connection import RedisConnectionManager
 4 | from src.common.server import mcp
 5 | 
 6 | 
 7 | @mcp.tool()
 8 | async def publish(channel: str, message: str) -> str:
 9 |     """Publish a message to a Redis channel.
10 | 
11 |     Args:
12 |         channel: The Redis channel to publish to.
13 |         message: The message to send.
14 | 
15 |     Returns:
16 |         A success message or an error message.
17 |     """
18 |     try:
19 |         r = RedisConnectionManager.get_connection()
20 |         r.publish(channel, message)
21 |         return f"Message published to channel '{channel}'."
22 |     except RedisError as e:
23 |         return f"Error publishing message to channel '{channel}': {str(e)}"
24 | 
25 | 
26 | @mcp.tool()
27 | async def subscribe(channel: str) -> str:
28 |     """Subscribe to a Redis channel.
29 | 
30 |     Args:
31 |         channel: The Redis channel to subscribe to.
32 | 
33 |     Returns:
34 |         A success message or an error message.
35 |     """
36 |     try:
37 |         r = RedisConnectionManager.get_connection()
38 |         pubsub = r.pubsub()
39 |         pubsub.subscribe(channel)
40 |         return f"Subscribed to channel '{channel}'."
41 |     except RedisError as e:
42 |         return f"Error subscribing to channel '{channel}': {str(e)}"
43 | 
44 | 
45 | @mcp.tool()
46 | async def unsubscribe(channel: str) -> str:
47 |     """Unsubscribe from a Redis channel.
48 | 
49 |     Args:
50 |         channel: The Redis channel to unsubscribe from.
51 | 
52 |     Returns:
53 |         A success message or an error message.
54 |     """
55 |     try:
56 |         r = RedisConnectionManager.get_connection()
57 |         pubsub = r.pubsub()
58 |         pubsub.unsubscribe(channel)
59 |         return f"Unsubscribed from channel '{channel}'."
60 |     except RedisError as e:
61 |         return f"Error unsubscribing from channel '{channel}': {str(e)}"
62 | 
```

--------------------------------------------------------------------------------
/src/tools/string.py:
--------------------------------------------------------------------------------

```python
 1 | import json
 2 | from typing import Union, Optional
 3 | 
 4 | from redis.exceptions import RedisError
 5 | from redis import Redis
 6 | 
 7 | from src.common.connection import RedisConnectionManager
 8 | from src.common.server import mcp
 9 | 
10 | 
11 | @mcp.tool()
12 | async def set(
13 |     key: str,
14 |     value: Union[str, bytes, int, float, dict],
15 |     expiration: Optional[int] = None,
16 | ) -> str:
17 |     """Set a Redis string value with an optional expiration time.
18 | 
19 |     Args:
20 |         key (str): The key to set.
21 |         value (str, bytes, int, float, dict): The value to store.
22 |         expiration (int, optional): Expiration time in seconds.
23 | 
24 |     Returns:
25 |         str: Confirmation message or an error message.
26 |     """
27 |     if isinstance(value, bytes):
28 |         encoded_value = value
29 |     elif isinstance(value, dict):
30 |         encoded_value = json.dumps(value)
31 |     else:
32 |         encoded_value = str(value)
33 | 
34 |     if isinstance(encoded_value, str):
35 |         encoded_value = encoded_value.encode("utf-8")
36 | 
37 |     try:
38 |         r: Redis = RedisConnectionManager.get_connection()
39 |         if expiration:
40 |             r.setex(key, expiration, encoded_value)
41 |         else:
42 |             r.set(key, encoded_value)
43 | 
44 |         return f"Successfully set {key}" + (
45 |             f" with expiration {expiration} seconds" if expiration else ""
46 |         )
47 |     except RedisError as e:
48 |         return f"Error setting key {key}: {str(e)}"
49 | 
50 | 
51 | @mcp.tool()
52 | async def get(key: str) -> Union[str, bytes]:
53 |     """Get a Redis string value.
54 | 
55 |     Args:
56 |         key (str): The key to retrieve.
57 | 
58 |     Returns:
59 |         str, bytes: The stored value or an error message.
60 |     """
61 |     try:
62 |         r: Redis = RedisConnectionManager.get_connection()
63 |         value = r.get(key)
64 | 
65 |         if value is None:
66 |             return f"Key {key} does not exist"
67 | 
68 |         if isinstance(value, bytes):
69 |             try:
70 |                 text = value.decode("utf-8")
71 |                 return text
72 |             except UnicodeDecodeError:
73 |                 return value
74 | 
75 |         return value
76 |     except RedisError as e:
77 |         return f"Error retrieving key {key}: {str(e)}"
78 | 
```

--------------------------------------------------------------------------------
/src/common/logging_utils.py:
--------------------------------------------------------------------------------

```python
 1 | import logging
 2 | import os
 3 | import sys
 4 | 
 5 | 
 6 | def resolve_log_level() -> int:
 7 |     """Resolve desired log level from MCP_REDIS_LOG_LEVEL.
 8 | 
 9 |     Accepts numeric strings or standard level names (DEBUG, INFO, WARNING,
10 |     ERROR, CRITICAL, NOTSET) including aliases WARN and FATAL. Defaults to WARNING.
11 |     """
12 |     name = os.getenv("MCP_REDIS_LOG_LEVEL")
13 |     if name:
14 |         s = name.strip()
15 |         try:
16 |             return int(s)
17 |         except ValueError:
18 |             pass
19 |         level = getattr(logging, s.upper(), None)
20 |         if isinstance(level, int):
21 |             return level
22 |     return logging.WARNING
23 | 
24 | 
25 | def configure_logging() -> int:
26 |     """Configure logging based on environment.
27 | 
28 |     - Default level WARNING
29 |     - MCP_REDIS_LOG_LEVEL to override
30 | 
31 |     Returns the resolved log level. Idempotent.
32 |     """
33 | 
34 |     level = resolve_log_level()
35 |     root = logging.getLogger()
36 | 
37 |     # Always set the root logger level
38 |     root.setLevel(level)
39 | 
40 |     # Only lower overly-restrictive handler thresholds to avoid host filtering.
41 |     # - Leave NOTSET (0) alone so it defers to logger/root levels
42 |     # - Do not raise handler thresholds (respect host-configured verbosity)
43 |     for h in root.handlers:
44 |         try:
45 |             cur = getattr(h, "level", None)
46 |             if isinstance(cur, int) and cur != logging.NOTSET and cur > level:
47 |                 h.setLevel(level)
48 |         except Exception:
49 |             # Log at DEBUG to avoid noisy stderr while still providing diagnostics.
50 |             logging.getLogger(__name__).debug(
51 |                 "Failed to adjust handler level for handler %r", h, exc_info=True
52 |             )
53 | 
54 |     # Only add our own stderr handler if there are NO handlers at all.
55 |     # Many hosts (pytest, uv, VS Code) install a console handler already.
56 |     if not root.handlers:
57 |         sh = logging.StreamHandler(sys.stderr)
58 |         sh.setLevel(level)
59 |         sh.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
60 |         root.addHandler(sh)
61 | 
62 |     # Route warnings.warn(...) through logging
63 |     logging.captureWarnings(True)
64 | 
65 |     return level
66 | 
```

--------------------------------------------------------------------------------
/src/tools/set.py:
--------------------------------------------------------------------------------

```python
 1 | from typing import Union, List, Optional
 2 | 
 3 | from redis.exceptions import RedisError
 4 | 
 5 | from src.common.connection import RedisConnectionManager
 6 | from src.common.server import mcp
 7 | 
 8 | 
 9 | @mcp.tool()
10 | async def sadd(name: str, value: str, expire_seconds: Optional[int] = None) -> str:
11 |     """Add a value to a Redis set with an optional expiration time.
12 | 
13 |     Args:
14 |         name: The Redis set key.
15 |         value: The value to add to the set.
16 |         expire_seconds: Optional; time in seconds after which the set should expire.
17 | 
18 |     Returns:
19 |         A success message or an error message.
20 |     """
21 |     try:
22 |         r = RedisConnectionManager.get_connection()
23 |         r.sadd(name, value)
24 | 
25 |         if expire_seconds is not None:
26 |             r.expire(name, expire_seconds)
27 | 
28 |         return f"Value '{value}' added successfully to set '{name}'." + (
29 |             f" Expires in {expire_seconds} seconds." if expire_seconds else ""
30 |         )
31 |     except RedisError as e:
32 |         return f"Error adding value '{value}' to set '{name}': {str(e)}"
33 | 
34 | 
35 | @mcp.tool()
36 | async def srem(name: str, value: str) -> str:
37 |     """Remove a value from a Redis set.
38 | 
39 |     Args:
40 |         name: The Redis set key.
41 |         value: The value to remove from the set.
42 | 
43 |     Returns:
44 |         A success message or an error message.
45 |     """
46 |     try:
47 |         r = RedisConnectionManager.get_connection()
48 |         removed = r.srem(name, value)
49 |         return (
50 |             f"Value '{value}' removed from set '{name}'."
51 |             if removed
52 |             else f"Value '{value}' not found in set '{name}'."
53 |         )
54 |     except RedisError as e:
55 |         return f"Error removing value '{value}' from set '{name}': {str(e)}"
56 | 
57 | 
58 | @mcp.tool()
59 | async def smembers(name: str) -> Union[str, List[str]]:
60 |     """Get all members of a Redis set.
61 | 
62 |     Args:
63 |         name: The Redis set key.
64 | 
65 |     Returns:
66 |         A list of values in the set or an error message.
67 |     """
68 |     try:
69 |         r = RedisConnectionManager.get_connection()
70 |         members = r.smembers(name)
71 |         return list(members) if members else f"Set '{name}' is empty or does not exist."
72 |     except RedisError as e:
73 |         return f"Error retrieving members of set '{name}': {str(e)}"
74 | 
```

--------------------------------------------------------------------------------
/src/tools/stream.py:
--------------------------------------------------------------------------------

```python
 1 | from typing import Dict, Any, Optional
 2 | 
 3 | from redis.exceptions import RedisError
 4 | 
 5 | from src.common.connection import RedisConnectionManager
 6 | from src.common.server import mcp
 7 | 
 8 | 
 9 | @mcp.tool()
10 | async def xadd(
11 |     key: str, fields: Dict[str, Any], expiration: Optional[int] = None
12 | ) -> str:
13 |     """Add an entry to a Redis stream with an optional expiration time.
14 | 
15 |     Args:
16 |         key (str): The stream key.
17 |         fields (dict): The fields and values for the stream entry.
18 |         expiration (int, optional): Expiration time in seconds.
19 | 
20 |     Returns:
21 |         str: The ID of the added entry or an error message.
22 |     """
23 |     try:
24 |         r = RedisConnectionManager.get_connection()
25 |         entry_id = r.xadd(key, fields)
26 |         if expiration:
27 |             r.expire(key, expiration)
28 |         return f"Successfully added entry {entry_id} to {key}" + (
29 |             f" with expiration {expiration} seconds" if expiration else ""
30 |         )
31 |     except RedisError as e:
32 |         return f"Error adding to stream {key}: {str(e)}"
33 | 
34 | 
35 | @mcp.tool()
36 | async def xrange(key: str, count: int = 1) -> str:
37 |     """Read entries from a Redis stream.
38 | 
39 |     Args:
40 |         key (str): The stream key.
41 |         count (int, optional): Number of entries to retrieve.
42 | 
43 |     Returns:
44 |         str: The retrieved stream entries or an error message.
45 |     """
46 |     try:
47 |         r = RedisConnectionManager.get_connection()
48 |         entries = r.xrange(key, count=count)
49 |         return str(entries) if entries else f"Stream {key} is empty or does not exist"
50 |     except RedisError as e:
51 |         return f"Error reading from stream {key}: {str(e)}"
52 | 
53 | 
54 | @mcp.tool()
55 | async def xdel(key: str, entry_id: str) -> str:
56 |     """Delete an entry from a Redis stream.
57 | 
58 |     Args:
59 |         key (str): The stream key.
60 |         entry_id (str): The ID of the entry to delete.
61 | 
62 |     Returns:
63 |         str: Confirmation message or an error message.
64 |     """
65 |     try:
66 |         r = RedisConnectionManager.get_connection()
67 |         result = r.xdel(key, entry_id)
68 |         return (
69 |             f"Successfully deleted entry {entry_id} from {key}"
70 |             if result
71 |             else f"Entry {entry_id} not found in {key}"
72 |         )
73 |     except RedisError as e:
74 |         return f"Error deleting from stream {key}: {str(e)}"
75 | 
```

--------------------------------------------------------------------------------
/src/tools/sorted_set.py:
--------------------------------------------------------------------------------

```python
 1 | from typing import Optional
 2 | 
 3 | from redis.exceptions import RedisError
 4 | 
 5 | from src.common.connection import RedisConnectionManager
 6 | from src.common.server import mcp
 7 | 
 8 | 
 9 | @mcp.tool()
10 | async def zadd(
11 |     key: str, score: float, member: str, expiration: Optional[int] = None
12 | ) -> str:
13 |     """Add a member to a Redis sorted set with an optional expiration time.
14 | 
15 |     Args:
16 |         key (str): The sorted set key.
17 |         score (float): The score of the member.
18 |         member (str): The member to add.
19 |         expiration (int, optional): Expiration time in seconds.
20 | 
21 |     Returns:
22 |         str: Confirmation message or an error message.
23 |     """
24 |     try:
25 |         r = RedisConnectionManager.get_connection()
26 |         r.zadd(key, {member: score})
27 |         if expiration:
28 |             r.expire(key, expiration)
29 |         return f"Successfully added {member} to {key} with score {score}" + (
30 |             f" and expiration {expiration} seconds" if expiration else ""
31 |         )
32 |     except RedisError as e:
33 |         return f"Error adding to sorted set {key}: {str(e)}"
34 | 
35 | 
36 | @mcp.tool()
37 | async def zrange(key: str, start: int, end: int, with_scores: bool = False) -> str:
38 |     """Retrieve a range of members from a Redis sorted set.
39 | 
40 |     Args:
41 |         key (str): The sorted set key.
42 |         start (int): The starting index.
43 |         end (int): The ending index.
44 |         with_scores (bool, optional): Whether to include scores in the result.
45 | 
46 |     Returns:
47 |         str: The sorted set members in the given range or an error message.
48 |     """
49 |     try:
50 |         r = RedisConnectionManager.get_connection()
51 |         members = r.zrange(key, start, end, withscores=with_scores)
52 |         return (
53 |             str(members) if members else f"Sorted set {key} is empty or does not exist"
54 |         )
55 |     except RedisError as e:
56 |         return f"Error retrieving sorted set {key}: {str(e)}"
57 | 
58 | 
59 | @mcp.tool()
60 | async def zrem(key: str, member: str) -> str:
61 |     """Remove a member from a Redis sorted set.
62 | 
63 |     Args:
64 |         key (str): The sorted set key.
65 |         member (str): The member to remove.
66 | 
67 |     Returns:
68 |         str: Confirmation message or an error message.
69 |     """
70 |     try:
71 |         r = RedisConnectionManager.get_connection()
72 |         result = r.zrem(key, member)
73 |         return (
74 |             f"Successfully removed {member} from {key}"
75 |             if result
76 |             else f"Member {member} not found in {key}"
77 |         )
78 |     except RedisError as e:
79 |         return f"Error removing from sorted set {key}: {str(e)}"
80 | 
```

--------------------------------------------------------------------------------
/examples/redis_assistant.py:
--------------------------------------------------------------------------------

```python
 1 | import asyncio
 2 | from agents import Agent, Runner
 3 | from openai.types.responses import ResponseTextDeltaEvent
 4 | from agents.mcp import MCPServerStdio
 5 | from collections import deque
 6 | 
 7 | 
 8 | # Set up and create the agent
 9 | async def build_agent():
10 |     # Redis MCP Server. Pass the environment configuration for the MCP Server in the JSON
11 |     server = MCPServerStdio(
12 |         params={
13 |             "command": "uv",
14 |             "args": [
15 |                 "--directory",
16 |                 "../src/",  # change with the path to the MCP server
17 |                 "run",
18 |                 "main.py",
19 |             ],
20 |             "env": {
21 |                 "REDIS_HOST": "127.0.0.1",
22 |                 "REDIS_PORT": "6379",
23 |                 "REDIS_USERNAME": "default",
24 |                 "REDIS_PWD": "",
25 |             },
26 |         }
27 |     )
28 | 
29 |     await server.connect()
30 | 
31 |     # Create and return the agent
32 |     agent = Agent(
33 |         name="Redis Assistant",
34 |         instructions="You are a helpful assistant capable of reading and writing to Redis. Store every question and answer in the Redis Stream app:logger",
35 |         mcp_servers=[server],
36 |     )
37 | 
38 |     return agent
39 | 
40 | 
41 | # CLI interaction
42 | async def cli(agent, max_history=30):
43 |     print("🔧 Redis Assistant CLI — Ask me something (type 'exit' to quit):\n")
44 |     conversation_history = deque(maxlen=max_history)
45 | 
46 |     while True:
47 |         q = input("❓> ")
48 |         if q.strip().lower() in {"exit", "quit"}:
49 |             break
50 | 
51 |         if len(q.strip()) > 0:
52 |             # Format the context into a single string
53 |             history = ""
54 |             for turn in conversation_history:
55 |                 prefix = "User" if turn["role"] == "user" else "Assistant"
56 |                 history += f"{prefix}: {turn['content']}\n"
57 | 
58 |             context = f"Conversation history:/n{history.strip()} /n New question:/n{q.strip()}"
59 |             result = Runner.run_streamed(agent, context)
60 | 
61 |             response_text = ""
62 |             async for event in result.stream_events():
63 |                 if event.type == "raw_response_event" and isinstance(
64 |                     event.data, ResponseTextDeltaEvent
65 |                 ):
66 |                     print(event.data.delta, end="", flush=True)
67 |                     response_text += event.data.delta
68 |             print("\n")
69 | 
70 |             # Add the user's message and the assistant's reply in history
71 |             conversation_history.append({"role": "user", "content": q})
72 |             conversation_history.append({"role": "assistant", "content": response_text})
73 | 
74 | 
75 | # Main entry point
76 | async def main():
77 |     agent = await build_agent()
78 |     await cli(agent)
79 | 
80 | 
81 | if __name__ == "__main__":
82 |     asyncio.run(main())
83 | 
```

--------------------------------------------------------------------------------
/src/tools/list.py:
--------------------------------------------------------------------------------

```python
 1 | import json
 2 | from typing import Union, List, Optional
 3 | 
 4 | from redis.exceptions import RedisError
 5 | from redis.typing import FieldT
 6 | 
 7 | from src.common.connection import RedisConnectionManager
 8 | from src.common.server import mcp
 9 | 
10 | 
11 | @mcp.tool()
12 | async def lpush(name: str, value: FieldT, expire: Optional[int] = None) -> str:
13 |     """Push a value onto the left of a Redis list and optionally set an expiration time."""
14 |     try:
15 |         r = RedisConnectionManager.get_connection()
16 |         r.lpush(name, value)
17 |         if expire:
18 |             r.expire(name, expire)
19 |         return f"Value '{value}' pushed to the left of list '{name}'."
20 |     except RedisError as e:
21 |         return f"Error pushing value to list '{name}': {str(e)}"
22 | 
23 | 
24 | @mcp.tool()
25 | async def rpush(name: str, value: FieldT, expire: Optional[int] = None) -> str:
26 |     """Push a value onto the right of a Redis list and optionally set an expiration time."""
27 |     try:
28 |         r = RedisConnectionManager.get_connection()
29 |         r.rpush(name, value)
30 |         if expire:
31 |             r.expire(name, expire)
32 |         return f"Value '{value}' pushed to the right of list '{name}'."
33 |     except RedisError as e:
34 |         return f"Error pushing value to list '{name}': {str(e)}"
35 | 
36 | 
37 | @mcp.tool()
38 | async def lpop(name: str) -> str:
39 |     """Remove and return the first element from a Redis list."""
40 |     try:
41 |         r = RedisConnectionManager.get_connection()
42 |         value = r.lpop(name)
43 |         return value if value else f"List '{name}' is empty or does not exist."
44 |     except RedisError as e:
45 |         return f"Error popping value from list '{name}': {str(e)}"
46 | 
47 | 
48 | @mcp.tool()
49 | async def rpop(name: str) -> str:
50 |     """Remove and return the last element from a Redis list."""
51 |     try:
52 |         r = RedisConnectionManager.get_connection()
53 |         value = r.rpop(name)
54 |         return value if value else f"List '{name}' is empty or does not exist."
55 |     except RedisError as e:
56 |         return f"Error popping value from list '{name}': {str(e)}"
57 | 
58 | 
59 | @mcp.tool()
60 | async def lrange(name: str, start: int, stop: int) -> Union[str, List[str]]:
61 |     """Get elements from a Redis list within a specific range.
62 | 
63 |     Returns:
64 |     str: A JSON string containing the list of elements or an error message.
65 |     """
66 |     try:
67 |         r = RedisConnectionManager.get_connection()
68 |         values = r.lrange(name, start, stop)
69 |         if not values:
70 |             return f"List '{name}' is empty or does not exist."
71 |         else:
72 |             return json.dumps(values)
73 |     except RedisError as e:
74 |         return f"Error retrieving values from list '{name}': {str(e)}"
75 | 
76 | 
77 | @mcp.tool()
78 | async def llen(name: str) -> int:
79 |     """Get the length of a Redis list."""
80 |     try:
81 |         r = RedisConnectionManager.get_connection()
82 |         return r.llen(name)
83 |     except RedisError as e:
84 |         return f"Error retrieving length of list '{name}': {str(e)}"
85 | 
```

--------------------------------------------------------------------------------
/src/tools/json.py:
--------------------------------------------------------------------------------

```python
 1 | import json
 2 | from typing import Optional
 3 | from redis.exceptions import RedisError
 4 | 
 5 | from src.common.connection import RedisConnectionManager
 6 | from src.common.server import mcp
 7 | 
 8 | 
 9 | @mcp.tool()
10 | async def json_set(
11 |     name: str,
12 |     path: str,
13 |     value: str,
14 |     expire_seconds: Optional[int] = None,
15 | ) -> str:
16 |     """Set a JSON value in Redis at a given path with an optional expiration time.
17 | 
18 |     Args:
19 |         name: The Redis key where the JSON document is stored.
20 |         path: The JSON path where the value should be set.
21 |         value: The JSON value to store (as JSON string, or will be auto-converted).
22 |         expire_seconds: Optional; time in seconds after which the key should expire.
23 | 
24 |     Returns:
25 |         A success message or an error message.
26 |     """
27 |     # Try to parse the value as JSON, if it fails, treat it as a plain string
28 |     try:
29 |         parsed_value = json.loads(value)
30 |     except (json.JSONDecodeError, TypeError):
31 |         parsed_value = value
32 | 
33 |     try:
34 |         r = RedisConnectionManager.get_connection()
35 |         r.json().set(name, path, parsed_value)
36 | 
37 |         if expire_seconds is not None:
38 |             r.expire(name, expire_seconds)
39 | 
40 |         return f"JSON value set at path '{path}' in '{name}'." + (
41 |             f" Expires in {expire_seconds} seconds." if expire_seconds else ""
42 |         )
43 |     except RedisError as e:
44 |         return f"Error setting JSON value at path '{path}' in '{name}': {str(e)}"
45 | 
46 | 
47 | @mcp.tool()
48 | async def json_get(name: str, path: str = "$") -> str:
49 |     """Retrieve a JSON value from Redis at a given path.
50 | 
51 |     Args:
52 |         name: The Redis key where the JSON document is stored.
53 |         path: The JSON path to retrieve (default: root '$').
54 | 
55 |     Returns:
56 |         The retrieved JSON value or an error message.
57 |     """
58 |     try:
59 |         r = RedisConnectionManager.get_connection()
60 |         value = r.json().get(name, path)
61 |         if value is not None:
62 |             # Convert the value to JSON string for consistent return type
63 |             return json.dumps(value, ensure_ascii=False, indent=2)
64 |         else:
65 |             return f"No data found at path '{path}' in '{name}'."
66 |     except RedisError as e:
67 |         return f"Error retrieving JSON value at path '{path}' in '{name}': {str(e)}"
68 | 
69 | 
70 | @mcp.tool()
71 | async def json_del(name: str, path: str = "$") -> str:
72 |     """Delete a JSON value from Redis at a given path.
73 | 
74 |     Args:
75 |         name: The Redis key where the JSON document is stored.
76 |         path: The JSON path to delete (default: root '$').
77 | 
78 |     Returns:
79 |         A success message or an error message.
80 |     """
81 |     try:
82 |         r = RedisConnectionManager.get_connection()
83 |         deleted = r.json().delete(name, path)
84 |         return (
85 |             f"Deleted JSON value at path '{path}' in '{name}'."
86 |             if deleted
87 |             else f"No JSON value found at path '{path}' in '{name}'."
88 |         )
89 |     except RedisError as e:
90 |         return f"Error deleting JSON value at path '{path}' in '{name}': {str(e)}"
91 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
  1 | [project]
  2 | name = "redis-mcp-server"
  3 | version = "0.3.5"
  4 | description = "Redis MCP Server - Model Context Protocol server for Redis"
  5 | readme = "README.md"
  6 | requires-python = ">=3.10"
  7 | license = "MIT"
  8 | authors = [
  9 |     {name = "Redis", email = "[email protected]"}
 10 | ]
 11 | keywords = ["redis", "mcp", "model-context-protocol", "ai", "llm"]
 12 | classifiers = [
 13 |     "Development Status :: 4 - Beta",
 14 |     "Intended Audience :: Developers",
 15 |     "Programming Language :: Python :: 3",
 16 |     "Programming Language :: Python :: 3.10",
 17 |     "Programming Language :: Python :: 3.11",
 18 |     "Programming Language :: Python :: 3.12",
 19 |     "Programming Language :: Python :: 3.13",
 20 |     "Programming Language :: Python :: 3.14",
 21 |     "Topic :: Database",
 22 |     "Topic :: Software Development :: Libraries :: Python Modules",
 23 | ]
 24 | dependencies = [
 25 |     "mcp[cli]>=1.9.4",
 26 |     "redis>=6.0.0",
 27 |     "dotenv>=0.9.9",
 28 |     "numpy>=2.2.4",
 29 |     "click>=8.0.0",
 30 |     "redis-entraid>=1.0.0",
 31 | ]
 32 | 
 33 | [project.scripts]
 34 | redis-mcp-server = "src.main:cli"
 35 | 
 36 | [project.urls]
 37 | Homepage = "https://github.com/redis/mcp-redis"
 38 | Repository = "https://github.com/redis/mcp-redis"
 39 | Issues = "https://github.com/redis/mcp-redis/issues"
 40 | 
 41 | [build-system]
 42 | requires = ["uv_build>=0.8.3,<0.10.0"]
 43 | build-backend = "uv_build"
 44 | 
 45 | [tool.uv.build-backend]
 46 | module-name = "src"
 47 | module-root = "."
 48 | 
 49 | # Security configuration for bandit
 50 | [tool.bandit]
 51 | exclude_dirs = ["tests", "build", "dist"]
 52 | skips = ["B101", "B601"]  # Skip assert_used and shell_injection_process_args if needed
 53 | 
 54 | [tool.bandit.assert_used]
 55 | skips = ["*_test.py", "*/test_*.py"]
 56 | 
 57 | 
 58 | 
 59 | [dependency-groups]
 60 | dev = [
 61 |     "bandit[toml]>=1.8.6",
 62 |     "black>=25.1.0",
 63 |     "coverage>=7.10.1",
 64 |     "mypy>=1.17.0",
 65 |     "pytest>=8.4.1",
 66 |     "pytest-asyncio>=1.1.0",
 67 |     "pytest-cov>=6.2.1",
 68 |     "pytest-mock>=3.12.0",
 69 |     "ruff>=0.12.5",
 70 |     "safety>=3.6.0",
 71 |     "twine>=4.0",
 72 | ]
 73 | 
 74 | test = [
 75 |     "pytest>=8.4.1",
 76 |     "pytest-asyncio>=1.1.0",
 77 |     "pytest-cov>=6.2.1",
 78 |     "pytest-mock>=3.12.0",
 79 |     "coverage>=7.10.1",
 80 | ]
 81 | 
 82 | # Testing configuration
 83 | [tool.pytest.ini_options]
 84 | testpaths = ["tests"]
 85 | python_files = ["test_*.py"]
 86 | python_classes = ["Test*"]
 87 | python_functions = ["test_*"]
 88 | addopts = [
 89 |     "--strict-markers",
 90 |     "--strict-config",
 91 |     "--verbose",
 92 |     "--cov=src",
 93 |     "--cov-report=html",
 94 |     "--cov-report=term",
 95 |     "--cov-report=xml",
 96 |     "--cov-fail-under=80",
 97 | ]
 98 | markers = [
 99 |     "unit: marks tests as unit tests",
100 |     "integration: marks tests as integration tests",
101 |     "slow: marks tests as slow running",
102 | ]
103 | asyncio_mode = "auto"
104 | filterwarnings = [
105 |     "ignore::DeprecationWarning",
106 |     "ignore::PendingDeprecationWarning",
107 | ]
108 | 
109 | [tool.coverage.run]
110 | source = ["src"]
111 | omit = [
112 |     "*/tests/*",
113 |     "*/test_*.py",
114 |     "*/__pycache__/*",
115 |     "*/venv/*",
116 |     "*/.venv/*",
117 | ]
118 | 
119 | [tool.coverage.report]
120 | exclude_lines = [
121 |     "pragma: no cover",
122 |     "def __repr__",
123 |     "if self.debug:",
124 |     "if settings.DEBUG",
125 |     "raise AssertionError",
126 |     "raise NotImplementedError",
127 |     "if 0:",
128 |     "if __name__ == .__main__.:",
129 |     "class .*\\bProtocol\\):",
130 |     "@(abc\\.)?abstractmethod",
131 | ]
132 | 
133 | 
134 | 
```

--------------------------------------------------------------------------------
/.github/workflows/stale-issues.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: "Stale Issue Management"
 2 | on:
 3 |   schedule:
 4 |     # Run daily at midnight UTC
 5 |     - cron: "0 0 * * *"
 6 |   workflow_dispatch: # Allow manual triggering
 7 | env:
 8 |   # Default stale policy timeframes
 9 |   DAYS_BEFORE_STALE: 365
10 |   DAYS_BEFORE_CLOSE: 30
11 |   # Accelerated timeline for needs-information issues
12 |   NEEDS_INFO_DAYS_BEFORE_STALE: 30
13 |   NEEDS_INFO_DAYS_BEFORE_CLOSE: 7
14 | jobs:
15 |   stale:
16 |     runs-on: ubuntu-latest
17 |     steps:
18 |       - uses: actions/stale@v10
19 |         with:
20 |           repo-token: ${{ secrets.GITHUB_TOKEN }}
21 |           dry-run: true
22 |           # Default stale policy
23 |           days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
24 |           days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
25 |           # Explicit stale label configuration
26 |           stale-issue-label: "stale"
27 |           stale-pr-label: "stale"
28 |           stale-issue-message: |
29 |             This issue has been automatically marked as stale due to inactivity. 
30 |             It will be closed in 30 days if no further activity occurs. 
31 |             If you believe this issue is still relevant, please add a comment to keep it open.
32 |           close-issue-message: |
33 |             This issue has been automatically closed due to inactivity. 
34 |             If you believe this issue is still relevant, please reopen it or create a new issue with updated information.
35 |           # Exclude needs-information issues from this job
36 |           exempt-issue-labels: 'no-stale,needs-information'
37 |           # Remove stale label when issue/PR becomes active again
38 |           remove-stale-when-updated: true
39 |           # Apply to pull requests with same timeline
40 |           days-before-pr-stale: ${{ env.DAYS_BEFORE_STALE }}
41 |           days-before-pr-close: ${{ env.DAYS_BEFORE_CLOSE }}
42 |           stale-pr-message: |
43 |             This pull request has been automatically marked as stale due to inactivity. 
44 |             It will be closed in 30 days if no further activity occurs.
45 |           close-pr-message: |
46 |             This pull request has been automatically closed due to inactivity. 
47 |             If you would like to continue this work, please reopen the PR or create a new one.
48 |           # Only exclude no-stale PRs (needs-information PRs follow standard timeline)
49 |           exempt-pr-labels: 'no-stale'
50 |   # Separate job for needs-information issues ONLY with accelerated timeline
51 |   stale-needs-info:
52 |     runs-on: ubuntu-latest
53 |     steps:
54 |       - uses: actions/stale@v10
55 |         with:
56 |           repo-token: ${{ secrets.GITHUB_TOKEN }}
57 |           dry-run: true
58 |           # Accelerated timeline for needs-information
59 |           days-before-stale: ${{ env.NEEDS_INFO_DAYS_BEFORE_STALE }}
60 |           days-before-close: ${{ env.NEEDS_INFO_DAYS_BEFORE_CLOSE }}
61 |           # Explicit stale label configuration
62 |           stale-issue-label: "stale"
63 |           # Only target ISSUES with needs-information label (not PRs)
64 |           only-issue-labels: 'needs-information'
65 |           stale-issue-message: |
66 |             This issue has been marked as stale because it requires additional information 
67 |             that has not been provided for 30 days. It will be closed in 7 days if the 
68 |             requested information is not provided.
69 |           close-issue-message: |
70 |             This issue has been closed because the requested information was not provided within the specified timeframe. 
71 |             If you can provide the missing information, please reopen this issue or create a new one.
72 |           # Disable PR processing for this job
73 |           days-before-pr-stale: -1
74 |           days-before-pr-close: -1
75 |           # Remove stale label when issue becomes active again
76 |           remove-stale-when-updated: true
```

--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Pytest configuration and fixtures for Redis MCP Server tests.
  3 | """
  4 | 
  5 | from unittest.mock import Mock, patch
  6 | 
  7 | import pytest
  8 | import redis
  9 | from redis.exceptions import ConnectionError, RedisError, TimeoutError
 10 | 
 11 | 
 12 | @pytest.fixture
 13 | def mock_redis():
 14 |     """Create a mock Redis connection."""
 15 |     mock = Mock(spec=redis.Redis)
 16 |     return mock
 17 | 
 18 | 
 19 | @pytest.fixture
 20 | def mock_redis_cluster():
 21 |     """Create a mock Redis Cluster connection."""
 22 |     mock = Mock(spec=redis.cluster.RedisCluster)
 23 |     return mock
 24 | 
 25 | 
 26 | @pytest.fixture
 27 | def mock_redis_connection_manager():
 28 |     """Mock the RedisConnectionManager to return a mock Redis connection."""
 29 |     with patch(
 30 |         "src.common.connection.RedisConnectionManager.get_connection"
 31 |     ) as mock_get_conn:
 32 |         mock_redis = Mock(spec=redis.Redis)
 33 |         mock_get_conn.return_value = mock_redis
 34 |         yield mock_redis
 35 | 
 36 | 
 37 | @pytest.fixture
 38 | def redis_config():
 39 |     """Sample Redis configuration for testing."""
 40 |     return {
 41 |         "host": "localhost",
 42 |         "port": 6379,
 43 |         "db": 0,
 44 |         "username": None,
 45 |         "password": "",
 46 |         "ssl": False,
 47 |         "ssl_ca_path": None,
 48 |         "ssl_keyfile": None,
 49 |         "ssl_certfile": None,
 50 |         "ssl_cert_reqs": "required",
 51 |         "ssl_ca_certs": None,
 52 |         "cluster_mode": False,
 53 |     }
 54 | 
 55 | 
 56 | @pytest.fixture
 57 | def redis_uri_samples():
 58 |     """Sample Redis URIs for testing."""
 59 |     return {
 60 |         "basic": "redis://localhost:6379/0",
 61 |         "with_auth": "redis://user:pass@localhost:6379/0",
 62 |         "ssl": "rediss://user:pass@localhost:6379/0",
 63 |         "with_query": "redis://localhost:6379/0?ssl_cert_reqs=required",
 64 |         "cluster": "redis://localhost:6379/0?cluster_mode=true",
 65 |     }
 66 | 
 67 | 
 68 | @pytest.fixture
 69 | def sample_vector():
 70 |     """Sample vector for testing vector operations."""
 71 |     return [0.1, 0.2, 0.3, 0.4, 0.5]
 72 | 
 73 | 
 74 | @pytest.fixture
 75 | def sample_json_data():
 76 |     """Sample JSON data for testing."""
 77 |     return {
 78 |         "name": "John Doe",
 79 |         "age": 30,
 80 |         "city": "New York",
 81 |         "hobbies": ["reading", "swimming"],
 82 |     }
 83 | 
 84 | 
 85 | @pytest.fixture
 86 | def redis_error_scenarios():
 87 |     """Common Redis error scenarios for testing."""
 88 |     return {
 89 |         "connection_error": ConnectionError("Connection refused"),
 90 |         "timeout_error": TimeoutError("Operation timed out"),
 91 |         "generic_error": RedisError("Generic Redis error"),
 92 |         "auth_error": RedisError("NOAUTH Authentication required"),
 93 |         "wrong_type": RedisError(
 94 |             "WRONGTYPE Operation against a key holding the wrong kind of value"
 95 |         ),
 96 |     }
 97 | 
 98 | 
 99 | @pytest.fixture(autouse=True)
100 | def reset_connection_manager():
101 |     """Reset the RedisConnectionManager singleton before each test."""
102 |     from src.common.connection import RedisConnectionManager
103 | 
104 |     RedisConnectionManager._instance = None
105 |     yield
106 |     RedisConnectionManager._instance = None
107 | 
108 | 
109 | @pytest.fixture
110 | def mock_numpy_array():
111 |     """Mock numpy array for vector testing."""
112 |     with patch("numpy.array") as mock_array:
113 |         mock_array.return_value.tobytes.return_value = b"mock_binary_data"
114 |         yield mock_array
115 | 
116 | 
117 | @pytest.fixture
118 | def mock_numpy_frombuffer():
119 |     """Mock numpy frombuffer for vector testing."""
120 |     with patch("numpy.frombuffer") as mock_frombuffer:
121 |         mock_frombuffer.return_value.tolist.return_value = [0.1, 0.2, 0.3]
122 |         yield mock_frombuffer
123 | 
124 | 
125 | # Async test helpers
126 | @pytest.fixture
127 | def event_loop():
128 |     """Create an event loop for async tests."""
129 |     import asyncio
130 | 
131 |     loop = asyncio.new_event_loop()
132 |     yield loop
133 |     loop.close()
134 | 
135 | 
136 | # Mark configurations
137 | def pytest_configure(config):
138 |     """Configure pytest markers."""
139 |     config.addinivalue_line("markers", "unit: mark test as a unit test")
140 |     config.addinivalue_line("markers", "integration: mark test as an integration test")
141 |     config.addinivalue_line("markers", "slow: mark test as slow running")
142 | 
```

--------------------------------------------------------------------------------
/tests/test_logging_utils.py:
--------------------------------------------------------------------------------

```python
  1 | import logging
  2 | import sys
  3 | import pytest
  4 | 
  5 | from src.common.logging_utils import resolve_log_level, configure_logging
  6 | 
  7 | 
  8 | @pytest.fixture()
  9 | def preserve_logging():
 10 |     """Snapshot and restore the root logger state to avoid cross-test interference."""
 11 |     root = logging.getLogger()
 12 |     saved_level = root.level
 13 |     saved_handlers = list(root.handlers)
 14 |     saved_handler_levels = [h.level for h in saved_handlers]
 15 |     try:
 16 |         yield
 17 |     finally:
 18 |         # Remove any handlers added during the test
 19 |         for h in list(root.handlers):
 20 |             try:
 21 |                 root.removeHandler(h)
 22 |             except Exception:
 23 |                 pass
 24 |         # Restore original handlers and their levels
 25 |         for h, lvl in zip(saved_handlers, saved_handler_levels):
 26 |             try:
 27 |                 root.addHandler(h)
 28 |                 h.setLevel(lvl)
 29 |             except Exception:
 30 |                 pass
 31 |         # Restore original root level
 32 |         root.setLevel(saved_level)
 33 |         # Best-effort: disable warnings capture enabled by configure_logging
 34 |         try:
 35 |             logging.captureWarnings(False)
 36 |         except Exception:
 37 |             pass
 38 | 
 39 | 
 40 | def test_resolve_log_level_default_warning(monkeypatch):
 41 |     monkeypatch.delenv("MCP_REDIS_LOG_LEVEL", raising=False)
 42 |     assert resolve_log_level() == logging.WARNING
 43 | 
 44 | 
 45 | def test_resolve_log_level_parses_name_and_alias(monkeypatch):
 46 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "info")
 47 |     assert resolve_log_level() == logging.INFO
 48 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "WARN")
 49 |     assert resolve_log_level() == logging.WARNING
 50 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "fatal")
 51 |     assert resolve_log_level() == logging.CRITICAL
 52 | 
 53 | 
 54 | def test_resolve_log_level_parses_numeric(monkeypatch):
 55 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "10")
 56 |     assert resolve_log_level() == 10
 57 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "+20")
 58 |     assert resolve_log_level() == 20
 59 | 
 60 | 
 61 | def test_configure_logging_adds_stderr_handler_when_none(monkeypatch, preserve_logging):
 62 |     # Ensure no handlers exist before configuring
 63 |     root = logging.getLogger()
 64 |     for h in list(root.handlers):
 65 |         root.removeHandler(h)
 66 | 
 67 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "INFO")
 68 |     level = configure_logging()
 69 | 
 70 |     assert level == logging.INFO
 71 |     assert len(root.handlers) == 1, (
 72 |         "Should add exactly one stderr handler when none exist"
 73 |     )
 74 |     handler = root.handlers[0]
 75 |     assert isinstance(handler, logging.StreamHandler)
 76 |     # StreamHandler exposes the underlying stream attribute
 77 |     assert getattr(handler, "stream", None) is sys.stderr
 78 |     assert handler.level == logging.INFO
 79 |     assert root.level == logging.INFO
 80 | 
 81 | 
 82 | def test_configure_logging_only_lowers_restrictive_handlers(
 83 |     monkeypatch, preserve_logging
 84 | ):
 85 |     root = logging.getLogger()
 86 |     # Start from a clean handler set
 87 |     for h in list(root.handlers):
 88 |         root.removeHandler(h)
 89 | 
 90 |     # Add two handlers: one restrictive WARNING, one permissive NOTSET
 91 |     h_warning = logging.StreamHandler(sys.stderr)
 92 |     h_warning.setLevel(logging.WARNING)
 93 |     root.addHandler(h_warning)
 94 | 
 95 |     h_notset = logging.StreamHandler(sys.stderr)
 96 |     h_notset.setLevel(logging.NOTSET)
 97 |     root.addHandler(h_notset)
 98 | 
 99 |     # Request DEBUG
100 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "DEBUG")
101 |     configure_logging()
102 | 
103 |     # The WARNING handler should be lowered to DEBUG; NOTSET should remain NOTSET
104 |     assert h_warning.level == logging.DEBUG
105 |     assert h_notset.level == logging.NOTSET
106 | 
107 | 
108 | def test_configure_logging_does_not_raise_handler_threshold(
109 |     monkeypatch, preserve_logging
110 | ):
111 |     root = logging.getLogger()
112 |     # Clean handlers
113 |     for h in list(root.handlers):
114 |         root.removeHandler(h)
115 | 
116 |     # Add a handler at WARNING and then set env to ERROR
117 |     h_warning = logging.StreamHandler(sys.stderr)
118 |     h_warning.setLevel(logging.WARNING)
119 |     root.addHandler(h_warning)
120 | 
121 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "ERROR")
122 |     configure_logging()
123 | 
124 |     # Handler should remain at WARNING (30), not be raised to ERROR (40)
125 |     assert h_warning.level == logging.WARNING
126 |     # Root level should reflect ERROR
127 |     assert root.level == logging.ERROR
128 | 
129 | 
130 | def test_configure_logging_does_not_add_handler_if_exists(
131 |     monkeypatch, preserve_logging
132 | ):
133 |     root = logging.getLogger()
134 |     # Start with one existing handler
135 |     for h in list(root.handlers):
136 |         root.removeHandler(h)
137 |     existing = logging.StreamHandler(sys.stderr)
138 |     root.addHandler(existing)
139 | 
140 |     monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "INFO")
141 |     configure_logging()
142 | 
143 |     # Should not add another handler
144 |     assert len(root.handlers) == 1
145 |     assert root.handlers[0] is existing
146 | 
```

--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Unit tests for src/common/server.py
  3 | """
  4 | 
  5 | from unittest.mock import patch
  6 | 
  7 | from src.common.server import mcp
  8 | 
  9 | 
 10 | class TestMCPServer:
 11 |     """Test cases for MCP server initialization."""
 12 | 
 13 |     def test_mcp_server_instance_exists(self):
 14 |         """Test that mcp server instance is created."""
 15 |         assert mcp is not None
 16 |         assert hasattr(mcp, "run")
 17 |         assert hasattr(mcp, "tool")
 18 | 
 19 |     def test_mcp_server_name(self):
 20 |         """Test that mcp server has correct name."""
 21 |         # The FastMCP server should have the correct name
 22 |         assert hasattr(mcp, "name") or hasattr(mcp, "_name")
 23 |         # We can't directly access the name in FastMCP, but we can verify it's a FastMCP instance
 24 |         assert str(type(mcp)) == "<class 'mcp.server.fastmcp.server.FastMCP'>"
 25 | 
 26 |     def test_mcp_server_dependencies(self):
 27 |         """Test that mcp server has correct dependencies."""
 28 |         # FastMCP should have dependencies configured
 29 |         # We can't directly test this without accessing private attributes
 30 |         # but we can verify the server was initialized properly
 31 |         assert mcp is not None
 32 | 
 33 |     @patch("mcp.server.fastmcp.FastMCP")
 34 |     def test_mcp_server_initialization(self, mock_fastmcp):
 35 |         """Test MCP server initialization with correct parameters."""
 36 |         # Re-import to trigger initialization
 37 |         import importlib
 38 | 
 39 |         import src.common.server
 40 | 
 41 |         importlib.reload(src.common.server)
 42 | 
 43 |         # Verify FastMCP was called with correct parameters
 44 |         mock_fastmcp.assert_called_once_with(
 45 |             "Redis MCP Server", dependencies=["redis", "dotenv", "numpy"]
 46 |         )
 47 | 
 48 |     def test_mcp_server_tool_decorator(self):
 49 |         """Test that mcp server provides tool decorator."""
 50 |         assert hasattr(mcp, "tool")
 51 |         assert callable(mcp.tool)
 52 | 
 53 |     def test_mcp_server_run_method(self):
 54 |         """Test that mcp server provides run method."""
 55 |         assert hasattr(mcp, "run")
 56 |         assert callable(mcp.run)
 57 | 
 58 |     @patch.object(mcp, "run")
 59 |     def test_mcp_server_run_can_be_called(self, mock_run):
 60 |         """Test that mcp server run method can be called."""
 61 |         mcp.run()
 62 |         mock_run.assert_called_once()
 63 | 
 64 |     def test_mcp_tool_decorator_functionality(self):
 65 |         """Test that the tool decorator can be used."""
 66 | 
 67 |         # Test that we can use the decorator (this tests the decorator exists and is callable)
 68 |         @mcp.tool()
 69 |         async def test_tool():
 70 |             """Test tool for decorator functionality."""
 71 |             return "test"
 72 | 
 73 |         # Verify the decorator worked
 74 |         assert callable(test_tool)
 75 |         assert hasattr(test_tool, "__name__")
 76 |         assert test_tool.__name__ == "test_tool"
 77 | 
 78 |     def test_mcp_tool_decorator_with_parameters(self):
 79 |         """Test that the tool decorator works with parameters."""
 80 | 
 81 |         @mcp.tool()
 82 |         async def test_tool_with_params(param1: str, param2: int = 10):
 83 |             """Test tool with parameters."""
 84 |             return f"{param1}:{param2}"
 85 | 
 86 |         # Verify the decorator worked
 87 |         assert callable(test_tool_with_params)
 88 |         assert hasattr(test_tool_with_params, "__name__")
 89 | 
 90 |     def test_mcp_server_is_singleton(self):
 91 |         """Test that importing server multiple times returns same instance."""
 92 |         from src.common.server import mcp as mcp1
 93 |         from src.common.server import mcp as mcp2
 94 | 
 95 |         assert mcp1 is mcp2
 96 |         assert id(mcp1) == id(mcp2)
 97 | 
 98 |     @patch("mcp.server.fastmcp.FastMCP")
 99 |     def test_mcp_server_dependencies_list(self, mock_fastmcp):
100 |         """Test that MCP server is initialized with correct dependencies list."""
101 |         # Re-import to trigger initialization
102 |         import importlib
103 | 
104 |         import src.common.server
105 | 
106 |         importlib.reload(src.common.server)
107 | 
108 |         # Get the call arguments
109 |         call_args = mock_fastmcp.call_args
110 |         assert call_args[0][0] == "Redis MCP Server"  # First positional argument
111 |         assert call_args[1]["dependencies"] == [
112 |             "redis",
113 |             "dotenv",
114 |             "numpy",
115 |         ]  # Keyword argument
116 | 
117 |     def test_mcp_server_type(self):
118 |         """Test that mcp server is of correct type."""
119 |         from mcp.server.fastmcp import FastMCP
120 | 
121 |         assert isinstance(mcp, FastMCP)
122 | 
123 |     def test_mcp_server_attributes(self):
124 |         """Test that mcp server has expected attributes."""
125 |         # Test for common FastMCP attributes
126 |         expected_attributes = ["run", "tool"]
127 | 
128 |         for attr in expected_attributes:
129 |             assert hasattr(mcp, attr), f"MCP server missing attribute: {attr}"
130 |             assert callable(getattr(mcp, attr)), (
131 |                 f"MCP server attribute {attr} is not callable"
132 |             )
133 | 
```

--------------------------------------------------------------------------------
/src/common/connection.py:
--------------------------------------------------------------------------------

```python
  1 | import logging
  2 | from typing import Optional, Type, Union
  3 | 
  4 | import redis
  5 | from redis import Redis
  6 | from redis.cluster import RedisCluster
  7 | 
  8 | from src.common.config import REDIS_CFG, is_entraid_auth_enabled
  9 | from src.common.entraid_auth import (
 10 |     create_credential_provider,
 11 |     EntraIDAuthenticationError,
 12 | )
 13 | from src.version import __version__
 14 | 
 15 | _logger = logging.getLogger(__name__)
 16 | 
 17 | 
 18 | class RedisConnectionManager:
 19 |     _instance: Optional[Redis] = None
 20 | 
 21 |     @classmethod
 22 |     def get_connection(cls, decode_responses=True) -> Redis:
 23 |         if cls._instance is None:
 24 |             try:
 25 |                 # Create Entra ID credential provider if configured
 26 |                 credential_provider = None
 27 |                 if is_entraid_auth_enabled():
 28 |                     try:
 29 |                         credential_provider = create_credential_provider()
 30 |                     except EntraIDAuthenticationError as e:
 31 |                         _logger.error(
 32 |                             "Failed to create Entra ID credential provider: %s", e
 33 |                         )
 34 |                         raise
 35 | 
 36 |                 if REDIS_CFG["cluster_mode"]:
 37 |                     redis_class: Type[Union[Redis, RedisCluster]] = (
 38 |                         redis.cluster.RedisCluster
 39 |                     )
 40 |                     connection_params = {
 41 |                         "host": REDIS_CFG["host"],
 42 |                         "port": REDIS_CFG["port"],
 43 |                         "username": REDIS_CFG["username"],
 44 |                         "password": REDIS_CFG["password"],
 45 |                         "ssl": REDIS_CFG["ssl"],
 46 |                         "ssl_ca_path": REDIS_CFG["ssl_ca_path"],
 47 |                         "ssl_keyfile": REDIS_CFG["ssl_keyfile"],
 48 |                         "ssl_certfile": REDIS_CFG["ssl_certfile"],
 49 |                         "ssl_cert_reqs": REDIS_CFG["ssl_cert_reqs"],
 50 |                         "ssl_ca_certs": REDIS_CFG["ssl_ca_certs"],
 51 |                         "decode_responses": decode_responses,
 52 |                         "lib_name": f"redis-py(mcp-server_v{__version__})",
 53 |                         "max_connections_per_node": 10,
 54 |                     }
 55 | 
 56 |                     # Add credential provider if available
 57 |                     if credential_provider:
 58 |                         connection_params["credential_provider"] = credential_provider
 59 |                         # Note: Azure Redis Enterprise with EntraID uses plain text connections
 60 |                         # SSL setting is controlled by REDIS_SSL environment variable
 61 |                 else:
 62 |                     redis_class: Type[Union[Redis, RedisCluster]] = redis.Redis
 63 |                     connection_params = {
 64 |                         "host": REDIS_CFG["host"],
 65 |                         "port": REDIS_CFG["port"],
 66 |                         "db": REDIS_CFG["db"],
 67 |                         "username": REDIS_CFG["username"],
 68 |                         "password": REDIS_CFG["password"],
 69 |                         "ssl": REDIS_CFG["ssl"],
 70 |                         "ssl_ca_path": REDIS_CFG["ssl_ca_path"],
 71 |                         "ssl_keyfile": REDIS_CFG["ssl_keyfile"],
 72 |                         "ssl_certfile": REDIS_CFG["ssl_certfile"],
 73 |                         "ssl_cert_reqs": REDIS_CFG["ssl_cert_reqs"],
 74 |                         "ssl_ca_certs": REDIS_CFG["ssl_ca_certs"],
 75 |                         "decode_responses": decode_responses,
 76 |                         "lib_name": f"redis-py(mcp-server_v{__version__})",
 77 |                         "max_connections": 10,
 78 |                     }
 79 | 
 80 |                     # Add credential provider if available
 81 |                     if credential_provider:
 82 |                         connection_params["credential_provider"] = credential_provider
 83 |                         # Note: Azure Redis Enterprise with EntraID uses plain text connections
 84 |                         # SSL setting is controlled by REDIS_SSL environment variable
 85 | 
 86 |                 cls._instance = redis_class(**connection_params)
 87 | 
 88 |             except redis.exceptions.ConnectionError:
 89 |                 _logger.error("Failed to connect to Redis server")
 90 |                 raise
 91 |             except redis.exceptions.AuthenticationError:
 92 |                 _logger.error("Authentication failed")
 93 |                 raise
 94 |             except redis.exceptions.TimeoutError:
 95 |                 _logger.error("Connection timed out")
 96 |                 raise
 97 |             except redis.exceptions.ResponseError as e:
 98 |                 _logger.error("Response error: %s", e)
 99 |                 raise
100 |             except redis.exceptions.RedisError as e:
101 |                 _logger.error("Redis error: %s", e)
102 |                 raise
103 |             except redis.exceptions.ClusterError as e:
104 |                 _logger.error("Redis Cluster error: %s", e)
105 |                 raise
106 |             except Exception as e:
107 |                 _logger.error("Unexpected error: %s", e)
108 |                 raise
109 | 
110 |         return cls._instance
111 | 
```

--------------------------------------------------------------------------------
/src/tools/redis_query_engine.py:
--------------------------------------------------------------------------------

```python
  1 | import json
  2 | from typing import List, Optional, Union, Dict, Any
  3 | 
  4 | import numpy as np
  5 | from redis.commands.search.field import VectorField
  6 | from redis.commands.search.index_definition import IndexDefinition
  7 | from redis.commands.search.query import Query
  8 | from redis.exceptions import RedisError
  9 | 
 10 | from src.common.connection import RedisConnectionManager
 11 | from src.common.server import mcp
 12 | 
 13 | 
 14 | @mcp.tool()
 15 | async def get_indexes() -> str:
 16 |     """List of indexes in the Redis database
 17 | 
 18 |     Returns:
 19 |         str: A JSON string containing the list of indexes or an error message.
 20 |     """
 21 |     try:
 22 |         r = RedisConnectionManager.get_connection()
 23 |         return json.dumps(r.execute_command("FT._LIST"))
 24 |     except RedisError as e:
 25 |         return f"Error retrieving indexes: {str(e)}"
 26 | 
 27 | 
 28 | @mcp.tool()
 29 | async def get_index_info(index_name: str) -> str:
 30 |     """Retrieve schema and information about a specific Redis index using FT.INFO.
 31 | 
 32 |     Args:
 33 |         index_name (str): The name of the index to retrieve information about.
 34 | 
 35 |     Returns:
 36 |         str: Information about the specified index or an error message.
 37 |     """
 38 |     try:
 39 |         r = RedisConnectionManager.get_connection()
 40 |         info = r.ft(index_name).info()
 41 |         return json.dumps(info, ensure_ascii=False, indent=2)
 42 |     except RedisError as e:
 43 |         return f"Error retrieving index info: {str(e)}"
 44 | 
 45 | 
 46 | @mcp.tool()
 47 | async def get_indexed_keys_number(index_name: str) -> str:
 48 |     """Retrieve the number of indexed keys by the index
 49 | 
 50 |     Args:
 51 |         index_name (str): The name of the index to retrieve information about.
 52 | 
 53 |     Returns:
 54 |         str: Number of indexed keys as a string
 55 |     """
 56 |     try:
 57 |         r = RedisConnectionManager.get_connection()
 58 |         total = r.ft(index_name).search(Query("*")).total
 59 |         return str(total)
 60 |     except RedisError as e:
 61 |         return f"Error retrieving number of keys: {str(e)}"
 62 | 
 63 | 
 64 | @mcp.tool()
 65 | async def create_vector_index_hash(
 66 |     index_name: str = "vector_index",
 67 |     prefix: str = "doc:",
 68 |     vector_field: str = "vector",
 69 |     dim: int = 1536,
 70 |     distance_metric: str = "COSINE",
 71 | ) -> str:
 72 |     """
 73 |     Create a Redis 8 vector similarity index using HNSW on a Redis hash.
 74 | 
 75 |     This function sets up a Redis index for approximate nearest neighbor (ANN)
 76 |     search using the HNSW algorithm and float32 vector embeddings.
 77 | 
 78 |     Args:
 79 |         index_name: The name of the Redis index to create. Unless specifically required, use the default name for the index.
 80 |         prefix: The key prefix used to identify documents to index (e.g., 'doc:'). Unless specifically required, use the default prefix.
 81 |         vector_field: The name of the vector field to be indexed for similarity search. Unless specifically required, use the default field name
 82 |         dim: The dimensionality of the vectors stored under the vector_field.
 83 |         distance_metric: The distance function to use (e.g., 'COSINE', 'L2', 'IP').
 84 | 
 85 |     Returns:
 86 |         A string indicating whether the index was created successfully or an error message.
 87 |     """
 88 |     try:
 89 |         r = RedisConnectionManager.get_connection()
 90 | 
 91 |         index_def = IndexDefinition(prefix=[prefix])
 92 |         schema = VectorField(
 93 |             vector_field,
 94 |             "HNSW",
 95 |             {"TYPE": "FLOAT32", "DIM": dim, "DISTANCE_METRIC": distance_metric},
 96 |         )
 97 | 
 98 |         r.ft(index_name).create_index([schema], definition=index_def)
 99 |         return f"Index '{index_name}' created successfully."
100 |     except RedisError as e:
101 |         return f"Error creating index '{index_name}': {str(e)}"
102 | 
103 | 
104 | @mcp.tool()
105 | async def vector_search_hash(
106 |     query_vector: List[float],
107 |     index_name: str = "vector_index",
108 |     vector_field: str = "vector",
109 |     k: int = 5,
110 |     return_fields: Optional[List[str]] = None,
111 | ) -> Union[List[Dict[str, Any]], str]:
112 |     """
113 |     Perform a KNN vector similarity search using Redis 8 or later version on vectors stored in hash data structures.
114 | 
115 |     Args:
116 |         query_vector: List of floats to use as the query vector.
117 |         index_name: Name of the Redis index. Unless specifically specified, use the default index name.
118 |         vector_field: Name of the indexed vector field. Unless specifically required, use the default field name
119 |         k: Number of nearest neighbors to return.
120 |         return_fields: List of fields to return (optional).
121 | 
122 |     Returns:
123 |         A list of matched documents or an error message.
124 |     """
125 |     try:
126 |         r = RedisConnectionManager.get_connection()
127 | 
128 |         # Convert query vector to float32 binary blob
129 |         vector_blob = np.array(query_vector, dtype=np.float32).tobytes()
130 | 
131 |         # Build the KNN query
132 |         base_query = f"*=>[KNN {k} @{vector_field} $vec_param AS score]"
133 |         query = (
134 |             Query(base_query)
135 |             .sort_by("score")
136 |             .paging(0, k)
137 |             .return_fields("id", "score", *return_fields or [])
138 |             .dialect(2)
139 |         )
140 | 
141 |         # Perform the search with vector parameter
142 |         results = r.ft(index_name).search(
143 |             query, query_params={"vec_param": vector_blob}
144 |         )
145 | 
146 |         # Format and return the results
147 |         return [doc.__dict__ for doc in results.docs]
148 |     except RedisError as e:
149 |         return f"Error performing vector search on index '{index_name}': {str(e)}"
150 | 
```

--------------------------------------------------------------------------------
/src/common/entraid_auth.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Entra ID authentication provider factory for Redis MCP Server.
  3 | 
  4 | This module provides factory methods to create credential providers for different
  5 | Azure authentication flows based on configuration.
  6 | """
  7 | 
  8 | import logging
  9 | 
 10 | from src.common.config import (
 11 |     ENTRAID_CFG,
 12 |     is_entraid_auth_enabled,
 13 |     validate_entraid_config,
 14 | )
 15 | 
 16 | _logger = logging.getLogger(__name__)
 17 | 
 18 | # Reduce Azure SDK logging verbosity
 19 | logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(
 20 |     logging.WARNING
 21 | )
 22 | logging.getLogger("azure.identity").setLevel(logging.WARNING)
 23 | logging.getLogger("redis.auth.token_manager").setLevel(logging.WARNING)
 24 | 
 25 | # Import redis-entraid components only when needed
 26 | try:
 27 |     from redis_entraid.cred_provider import (
 28 |         create_from_default_azure_credential,
 29 |         create_from_managed_identity,
 30 |         create_from_service_principal,
 31 |         ManagedIdentityType,
 32 |         TokenManagerConfig,
 33 |         RetryPolicy,
 34 |     )
 35 | 
 36 |     ENTRAID_AVAILABLE = True
 37 | except ImportError:
 38 |     _logger.warning(
 39 |         "redis-entraid package not available. Entra ID authentication will be disabled."
 40 |     )
 41 |     ENTRAID_AVAILABLE = False
 42 | 
 43 | 
 44 | class EntraIDAuthenticationError(Exception):
 45 |     """Exception raised for Entra ID authentication configuration errors."""
 46 | 
 47 |     pass
 48 | 
 49 | 
 50 | def create_credential_provider():
 51 |     """
 52 |     Create an Entra ID credential provider based on the current configuration.
 53 | 
 54 |     Returns:
 55 |         Credential provider instance or None if Entra ID auth is not configured.
 56 | 
 57 |     Raises:
 58 |         EntraIDAuthenticationError: If configuration is invalid or required packages are missing.
 59 |     """
 60 |     if not is_entraid_auth_enabled():
 61 |         return None
 62 | 
 63 |     if not ENTRAID_AVAILABLE:
 64 |         raise EntraIDAuthenticationError(
 65 |             "redis-entraid package is required for Entra ID authentication. "
 66 |             "Install it with: pip install redis-entraid"
 67 |         )
 68 | 
 69 |     # Validate configuration
 70 |     is_valid, error_message = validate_entraid_config()
 71 |     if not is_valid:
 72 |         raise EntraIDAuthenticationError(
 73 |             f"Invalid Entra ID configuration: {error_message}"
 74 |         )
 75 | 
 76 |     auth_flow = ENTRAID_CFG["auth_flow"]
 77 | 
 78 |     try:
 79 |         # Create token manager configuration
 80 |         token_manager_config = _create_token_manager_config()
 81 | 
 82 |         if auth_flow == "service_principal":
 83 |             return _create_service_principal_provider(token_manager_config)
 84 |         elif auth_flow == "managed_identity":
 85 |             return _create_managed_identity_provider(token_manager_config)
 86 |         elif auth_flow == "default_credential":
 87 |             return _create_default_credential_provider(token_manager_config)
 88 |         else:
 89 |             raise EntraIDAuthenticationError(
 90 |                 f"Unsupported authentication flow: {auth_flow}"
 91 |             )
 92 | 
 93 |     except Exception as e:
 94 |         _logger.error("Failed to create Entra ID credential provider: %s", e)
 95 |         raise EntraIDAuthenticationError(f"Failed to create credential provider: {e}")
 96 | 
 97 | 
 98 | def _create_token_manager_config():
 99 |     """Create TokenManagerConfig from current configuration."""
100 |     retry_policy = RetryPolicy(
101 |         max_attempts=ENTRAID_CFG["retry_max_attempts"],
102 |         delay_in_ms=ENTRAID_CFG["retry_delay_ms"],
103 |     )
104 | 
105 |     return TokenManagerConfig(
106 |         expiration_refresh_ratio=ENTRAID_CFG["token_expiration_refresh_ratio"],
107 |         lower_refresh_bound_millis=ENTRAID_CFG["lower_refresh_bound_millis"],
108 |         token_request_execution_timeout_in_ms=ENTRAID_CFG[
109 |             "token_request_execution_timeout_ms"
110 |         ],
111 |         retry_policy=retry_policy,
112 |     )
113 | 
114 | 
115 | def _create_service_principal_provider(token_manager_config):
116 |     """Create service principal credential provider."""
117 | 
118 |     return create_from_service_principal(
119 |         client_id=ENTRAID_CFG["client_id"],
120 |         client_credential=ENTRAID_CFG["client_secret"],
121 |         tenant_id=ENTRAID_CFG["tenant_id"],
122 |         token_manager_config=token_manager_config,
123 |     )
124 | 
125 | 
126 | def _create_managed_identity_provider(token_manager_config):
127 |     """Create managed identity credential provider."""
128 |     identity_type_str = ENTRAID_CFG["identity_type"]
129 | 
130 |     if identity_type_str == "system_assigned":
131 |         identity_type = ManagedIdentityType.SYSTEM_ASSIGNED
132 | 
133 |         return create_from_managed_identity(
134 |             identity_type=identity_type,
135 |             resource=ENTRAID_CFG["resource"],
136 |             token_manager_config=token_manager_config,
137 |         )
138 | 
139 |     elif identity_type_str == "user_assigned":
140 |         identity_type = ManagedIdentityType.USER_ASSIGNED
141 | 
142 |         return create_from_managed_identity(
143 |             identity_type=identity_type,
144 |             resource=ENTRAID_CFG["resource"],
145 |             client_id=ENTRAID_CFG["user_assigned_identity_client_id"],
146 |             token_manager_config=token_manager_config,
147 |         )
148 | 
149 |     else:
150 |         raise EntraIDAuthenticationError(f"Invalid identity type: {identity_type_str}")
151 | 
152 | 
153 | def _create_default_credential_provider(token_manager_config):
154 |     """Create default Azure credential provider."""
155 | 
156 |     # Parse scopes from configuration
157 |     scopes_str = ENTRAID_CFG["scopes"]
158 |     scopes = tuple(scope.strip() for scope in scopes_str.split(","))
159 | 
160 |     return create_from_default_azure_credential(
161 |         scopes=scopes, token_manager_config=token_manager_config
162 |     )
163 | 
```

--------------------------------------------------------------------------------
/src/tools/hash.py:
--------------------------------------------------------------------------------

```python
  1 | from typing import List, Union, Optional
  2 | 
  3 | import numpy as np
  4 | from redis.exceptions import RedisError
  5 | 
  6 | from src.common.connection import RedisConnectionManager
  7 | from src.common.server import mcp
  8 | 
  9 | 
 10 | @mcp.tool()
 11 | async def hset(
 12 |     name: str, key: str, value: str | int | float, expire_seconds: Optional[int] = None
 13 | ) -> str:
 14 |     """Set a field in a hash stored at key with an optional expiration time.
 15 | 
 16 |     Args:
 17 |         name: The Redis hash key.
 18 |         key: The field name inside the hash.
 19 |         value: The value to set.
 20 |         expire_seconds: Optional; time in seconds after which the key should expire.
 21 | 
 22 |     Returns:
 23 |         A success message or an error message.
 24 |     """
 25 |     try:
 26 |         r = RedisConnectionManager.get_connection()
 27 |         r.hset(name, key, str(value))
 28 | 
 29 |         if expire_seconds is not None:
 30 |             r.expire(name, expire_seconds)
 31 | 
 32 |         return f"Field '{key}' set successfully in hash '{name}'." + (
 33 |             f" Expires in {expire_seconds} seconds." if expire_seconds else ""
 34 |         )
 35 |     except RedisError as e:
 36 |         return f"Error setting field '{key}' in hash '{name}': {str(e)}"
 37 | 
 38 | 
 39 | @mcp.tool()
 40 | async def hget(name: str, key: str) -> str:
 41 |     """Get the value of a field in a Redis hash.
 42 | 
 43 |     Args:
 44 |         name: The Redis hash key.
 45 |         key: The field name inside the hash.
 46 | 
 47 |     Returns:
 48 |         The field value or an error message.
 49 |     """
 50 |     try:
 51 |         r = RedisConnectionManager.get_connection()
 52 |         value = r.hget(name, key)
 53 |         return value if value else f"Field '{key}' not found in hash '{name}'."
 54 |     except RedisError as e:
 55 |         return f"Error getting field '{key}' from hash '{name}': {str(e)}"
 56 | 
 57 | 
 58 | @mcp.tool()
 59 | async def hdel(name: str, key: str) -> str:
 60 |     """Delete a field from a Redis hash.
 61 | 
 62 |     Args:
 63 |         name: The Redis hash key.
 64 |         key: The field name inside the hash.
 65 | 
 66 |     Returns:
 67 |         A success message or an error message.
 68 |     """
 69 |     try:
 70 |         r = RedisConnectionManager.get_connection()
 71 |         deleted = r.hdel(name, key)
 72 |         return (
 73 |             f"Field '{key}' deleted from hash '{name}'."
 74 |             if deleted
 75 |             else f"Field '{key}' not found in hash '{name}'."
 76 |         )
 77 |     except RedisError as e:
 78 |         return f"Error deleting field '{key}' from hash '{name}': {str(e)}"
 79 | 
 80 | 
 81 | @mcp.tool()
 82 | async def hgetall(name: str) -> dict:
 83 |     """Get all fields and values from a Redis hash.
 84 | 
 85 |     Args:
 86 |         name: The Redis hash key.
 87 | 
 88 |     Returns:
 89 |         A dictionary of field-value pairs or an error message.
 90 |     """
 91 |     try:
 92 |         r = RedisConnectionManager.get_connection()
 93 |         hash_data = r.hgetall(name)
 94 |         return (
 95 |             {k: v for k, v in hash_data.items()}
 96 |             if hash_data
 97 |             else f"Hash '{name}' is empty or does not exist."
 98 |         )
 99 |     except RedisError as e:
100 |         return f"Error getting all fields from hash '{name}': {str(e)}"
101 | 
102 | 
103 | @mcp.tool()
104 | async def hexists(name: str, key: str) -> bool:
105 |     """Check if a field exists in a Redis hash.
106 | 
107 |     Args:
108 |         name: The Redis hash key.
109 |         key: The field name inside the hash.
110 | 
111 |     Returns:
112 |         True if the field exists, False otherwise.
113 |     """
114 |     try:
115 |         r = RedisConnectionManager.get_connection()
116 |         return r.hexists(name, key)
117 |     except RedisError as e:
118 |         return f"Error checking existence of field '{key}' in hash '{name}': {str(e)}"
119 | 
120 | 
121 | @mcp.tool()
122 | async def set_vector_in_hash(
123 |     name: str, vector: List[float], vector_field: str = "vector"
124 | ) -> Union[bool, str]:
125 |     """Store a vector as a field in a Redis hash.
126 | 
127 |     Args:
128 |         name: The Redis hash key.
129 |         vector_field: The field name inside the hash. Unless specifically required, use the default field name
130 |         vector: The vector (list of numbers) to store in the hash.
131 | 
132 |     Returns:
133 |         True if the vector was successfully stored, False otherwise.
134 |     """
135 |     try:
136 |         r = RedisConnectionManager.get_connection()
137 | 
138 |         # Convert the vector to a NumPy array, then to a binary blob using np.float32
139 |         vector_array = np.array(vector, dtype=np.float32)
140 |         binary_blob = vector_array.tobytes()
141 | 
142 |         r.hset(name, vector_field, binary_blob)
143 |         return True
144 |     except RedisError as e:
145 |         return f"Error storing vector in hash '{name}' with field '{vector_field}': {str(e)}"
146 | 
147 | 
148 | @mcp.tool()
149 | async def get_vector_from_hash(name: str, vector_field: str = "vector"):
150 |     """Retrieve a vector from a Redis hash and convert it back from binary blob.
151 | 
152 |     Args:
153 |         name: The Redis hash key.
154 |         vector_field: The field name inside the hash. Unless specifically required, use the default field name
155 | 
156 |     Returns:
157 |         The vector as a list of floats, or an error message if retrieval fails.
158 |     """
159 |     try:
160 |         r = RedisConnectionManager.get_connection(decode_responses=False)
161 | 
162 |         # Retrieve the binary blob stored in the hash
163 |         binary_blob = r.hget(name, vector_field)
164 | 
165 |         if binary_blob:
166 |             # Convert the binary blob back to a NumPy array (assuming it's stored as float32)
167 |             vector_array = np.frombuffer(binary_blob, dtype=np.float32)
168 |             return vector_array.tolist()
169 |         else:
170 |             return f"Field '{vector_field}' not found in hash '{name}'."
171 | 
172 |     except RedisError as e:
173 |         return f"Error retrieving vector field '{vector_field}' from hash '{name}': {str(e)}"
174 | 
```

--------------------------------------------------------------------------------
/src/main.py:
--------------------------------------------------------------------------------

```python
  1 | import sys
  2 | import logging
  3 | 
  4 | import click
  5 | 
  6 | from src.common.config import (
  7 |     parse_redis_uri,
  8 |     set_redis_config_from_cli,
  9 |     set_entraid_config_from_cli,
 10 | )
 11 | from src.common.server import mcp
 12 | from src.common.logging_utils import configure_logging
 13 | 
 14 | 
 15 | class RedisMCPServer:
 16 |     def __init__(self):
 17 |         # Configure logging on server initialization (idempotent)
 18 |         configure_logging()
 19 |         self._logger = logging.getLogger(__name__)
 20 |         self._logger.info("Starting the Redis MCP Server")
 21 | 
 22 |     def run(self):
 23 |         mcp.run()
 24 | 
 25 | 
 26 | @click.command()
 27 | @click.option(
 28 |     "--url",
 29 |     help="Redis connection URI (redis://user:pass@host:port/db or rediss:// for SSL)",
 30 | )
 31 | @click.option("--host", default="127.0.0.1", help="Redis host")
 32 | @click.option("--port", default=6379, type=int, help="Redis port")
 33 | @click.option("--db", default=0, type=int, help="Redis database number")
 34 | @click.option("--username", help="Redis username")
 35 | @click.option("--password", help="Redis password")
 36 | @click.option("--ssl", is_flag=True, help="Use SSL connection")
 37 | @click.option("--ssl-ca-path", help="Path to CA certificate file")
 38 | @click.option("--ssl-keyfile", help="Path to SSL key file")
 39 | @click.option("--ssl-certfile", help="Path to SSL certificate file")
 40 | @click.option(
 41 |     "--ssl-cert-reqs", default="required", help="SSL certificate requirements"
 42 | )
 43 | @click.option("--ssl-ca-certs", help="Path to CA certificates file")
 44 | @click.option("--cluster-mode", is_flag=True, help="Enable Redis cluster mode")
 45 | # Entra ID Authentication Options
 46 | @click.option(
 47 |     "--entraid-auth-flow",
 48 |     type=click.Choice(["service_principal", "managed_identity", "default_credential"]),
 49 |     help="Entra ID authentication flow",
 50 | )
 51 | @click.option(
 52 |     "--entraid-client-id",
 53 |     help="Entra ID client ID (for service principal or user-assigned managed identity)",
 54 | )
 55 | @click.option(
 56 |     "--entraid-client-secret", help="Entra ID client secret (for service principal)"
 57 | )
 58 | @click.option("--entraid-tenant-id", help="Entra ID tenant ID (for service principal)")
 59 | @click.option(
 60 |     "--entraid-identity-type",
 61 |     type=click.Choice(["system_assigned", "user_assigned"]),
 62 |     default="system_assigned",
 63 |     help="Managed identity type",
 64 | )
 65 | @click.option(
 66 |     "--entraid-scopes",
 67 |     default="https://redis.azure.com/.default",
 68 |     help="Entra ID scopes (comma-separated)",
 69 | )
 70 | @click.option(
 71 |     "--entraid-resource", default="https://redis.azure.com/", help="Entra ID resource"
 72 | )
 73 | @click.option(
 74 |     "--entraid-token-refresh-ratio",
 75 |     type=float,
 76 |     default=0.9,
 77 |     help="Token expiration refresh ratio",
 78 | )
 79 | @click.option(
 80 |     "--entraid-retry-max-attempts",
 81 |     type=int,
 82 |     default=3,
 83 |     help="Maximum retry attempts for token requests",
 84 | )
 85 | @click.option(
 86 |     "--entraid-retry-delay-ms",
 87 |     type=int,
 88 |     default=100,
 89 |     help="Retry delay in milliseconds",
 90 | )
 91 | def cli(
 92 |     url,
 93 |     host,
 94 |     port,
 95 |     db,
 96 |     username,
 97 |     password,
 98 |     ssl,
 99 |     ssl_ca_path,
100 |     ssl_keyfile,
101 |     ssl_certfile,
102 |     ssl_cert_reqs,
103 |     ssl_ca_certs,
104 |     cluster_mode,
105 |     entraid_auth_flow,
106 |     entraid_client_id,
107 |     entraid_client_secret,
108 |     entraid_tenant_id,
109 |     entraid_identity_type,
110 |     entraid_scopes,
111 |     entraid_resource,
112 |     entraid_token_refresh_ratio,
113 |     entraid_retry_max_attempts,
114 |     entraid_retry_delay_ms,
115 | ):
116 |     """Redis MCP Server - Model Context Protocol server for Redis."""
117 | 
118 |     # Handle Redis URI if provided (and not empty)
119 |     # Note: gemini-cli passes the raw "${REDIS_URL}" string when the env var is not set
120 | 
121 |     if url and url.strip() and url.strip() != "${REDIS_URL}":
122 |         try:
123 |             uri_config = parse_redis_uri(url)
124 |             set_redis_config_from_cli(uri_config)
125 |         except ValueError as e:
126 |             click.echo(f"Error parsing Redis URI: {e}", err=True)
127 |             sys.exit(1)
128 |     else:
129 |         # Set individual Redis parameters
130 |         config = {
131 |             "host": host,
132 |             "port": port,
133 |             "db": db,
134 |             "ssl": ssl,
135 |             "cluster_mode": cluster_mode,
136 |         }
137 | 
138 |         if username:
139 |             config["username"] = username
140 |         if password:
141 |             config["password"] = password
142 |         if ssl_ca_path:
143 |             config["ssl_ca_path"] = ssl_ca_path
144 |         if ssl_keyfile:
145 |             config["ssl_keyfile"] = ssl_keyfile
146 |         if ssl_certfile:
147 |             config["ssl_certfile"] = ssl_certfile
148 |         if ssl_cert_reqs:
149 |             config["ssl_cert_reqs"] = ssl_cert_reqs
150 |         if ssl_ca_certs:
151 |             config["ssl_ca_certs"] = ssl_ca_certs
152 | 
153 |         set_redis_config_from_cli(config)
154 | 
155 |     # Handle Entra ID authentication configuration
156 |     entraid_config = {}
157 |     if entraid_auth_flow:
158 |         entraid_config["auth_flow"] = entraid_auth_flow
159 |     if entraid_client_id:
160 |         entraid_config["client_id"] = entraid_client_id
161 |     if entraid_client_secret:
162 |         entraid_config["client_secret"] = entraid_client_secret
163 |     if entraid_tenant_id:
164 |         entraid_config["tenant_id"] = entraid_tenant_id
165 |     if entraid_identity_type:
166 |         entraid_config["identity_type"] = entraid_identity_type
167 |     if entraid_scopes:
168 |         entraid_config["scopes"] = entraid_scopes
169 |     if entraid_resource:
170 |         entraid_config["resource"] = entraid_resource
171 |     if entraid_token_refresh_ratio is not None:
172 |         entraid_config["token_expiration_refresh_ratio"] = entraid_token_refresh_ratio
173 |     if entraid_retry_max_attempts is not None:
174 |         entraid_config["retry_max_attempts"] = entraid_retry_max_attempts
175 |     if entraid_retry_delay_ms is not None:
176 |         entraid_config["retry_delay_ms"] = entraid_retry_delay_ms
177 | 
178 |     # For user-assigned managed identity, use client_id as user_assigned_identity_client_id
179 |     if (
180 |         entraid_auth_flow == "managed_identity"
181 |         and entraid_identity_type == "user_assigned"
182 |         and entraid_client_id
183 |     ):
184 |         entraid_config["user_assigned_identity_client_id"] = entraid_client_id
185 | 
186 |     if entraid_config:
187 |         set_entraid_config_from_cli(entraid_config)
188 | 
189 |     # Start the server
190 |     server = RedisMCPServer()
191 |     server.run()
192 | 
193 | 
194 | def main():
195 |     """Legacy main function for backward compatibility."""
196 |     server = RedisMCPServer()
197 |     server.run()
198 | 
199 | 
200 | if __name__ == "__main__":
201 |     main()
202 | 
```

--------------------------------------------------------------------------------
/GEMINI.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Redis MCP Server Extension
  2 | 
  3 | This extension provides a natural language interface for managing and searching data in Redis through the Model Context Protocol (MCP).
  4 | 
  5 | ## What this extension provides
  6 | 
  7 | The Redis MCP Server enables AI agents to efficiently interact with Redis databases using natural language commands. You can:
  8 | 
  9 | - **Store and retrieve data**: Cache items, store session data, manage configuration values
 10 | - **Work with data structures**: Manage hashes, lists, sets, sorted sets, and streams
 11 | - **Search and filter**: Perform efficient data retrieval and searching operations
 12 | - **Pub/Sub messaging**: Publish and subscribe to real-time message channels
 13 | - **JSON operations**: Store, retrieve, and manipulate JSON documents
 14 | - **Vector search**: Manage vector indexes and perform similarity searches
 15 | 
 16 | ## Available Tools
 17 | 
 18 | ### String Operations
 19 | - Set, get, and manage string values with optional expiration
 20 | - Useful for caching, session data, and simple configuration
 21 | 
 22 | ### Hash Operations  
 23 | - Store field-value pairs within a single key
 24 | - Support for vector embeddings storage
 25 | - Ideal for user profiles, product information, and structured objects
 26 | 
 27 | ### List Operations
 28 | - Append, pop, and manage list items
 29 | - Perfect for queues, message brokers, and activity logs
 30 | 
 31 | ### Set Operations
 32 | - Add, remove, and list unique set members
 33 | - Perform set operations like intersection and union
 34 | - Great for tracking unique values and tags
 35 | 
 36 | ### Sorted Set Operations
 37 | - Manage score-based ordered data
 38 | - Ideal for leaderboards, priority queues, and time-based analytics
 39 | 
 40 | ### Pub/Sub Operations
 41 | - Publish messages to channels and subscribe to receive them
 42 | - Real-time notifications and chat applications
 43 | 
 44 | ### Stream Operations
 45 | - Add, read, and delete from data streams
 46 | - Event sourcing, activity feeds, and sensor data logging
 47 | 
 48 | ### JSON Operations
 49 | - Store, retrieve, and manipulate JSON documents
 50 | - Complex nested data structures with path-based access
 51 | 
 52 | ### Vector Search
 53 | - Manage vector indexes and perform similarity searches
 54 | - AI/ML applications and semantic search
 55 | 
 56 | ### Server Management
 57 | - Retrieve database information and statistics
 58 | - Monitor Redis server status and performance
 59 | 
 60 | ## Usage Examples
 61 | 
 62 | You can interact with Redis using natural language:
 63 | 
 64 | - "Store this user session data with a 1-hour expiration"
 65 | - "Add this item to the shopping cart list"
 66 | - "Search for similar vectors in the product embeddings"
 67 | - "Publish a notification to the alerts channel"
 68 | - "Get the top 10 scores from the leaderboard"
 69 | - "Cache this API response for 5 minutes"
 70 | 
 71 | ## Configuration
 72 | 
 73 | The extension connects to Redis using a Redis URL. Default configuration connects to `redis://127.0.0.1:6379/0`.
 74 | 
 75 | ### Primary Configuration: Redis URL
 76 | 
 77 | Set the `REDIS_URL` environment variable to configure your Redis connection:
 78 | 
 79 | ```bash
 80 | export REDIS_URL=redis://[username:password@]host:port/database
 81 | ```
 82 | 
 83 | ### Configuration Examples
 84 | 
 85 | **Local Redis (no authentication):**
 86 | ```bash
 87 | export REDIS_URL=redis://127.0.0.1:6379/0
 88 | # or
 89 | export REDIS_URL=redis://localhost:6379/0
 90 | ```
 91 | 
 92 | **Redis with password:**
 93 | ```bash
 94 | export REDIS_URL=redis://:mypassword@localhost:6379/0
 95 | ```
 96 | 
 97 | **Redis with username and password:**
 98 | ```bash
 99 | export REDIS_URL=redis://myuser:mypassword@localhost:6379/0
100 | ```
101 | 
102 | **Redis Cloud:**
103 | ```bash
104 | export REDIS_URL=redis://default:[email protected]:12345/0
105 | ```
106 | 
107 | **Redis with SSL:**
108 | ```bash
109 | export REDIS_URL=rediss://user:[email protected]:6380/0
110 | ```
111 | 
112 | **Redis with SSL and certificates:**
113 | ```bash
114 | export REDIS_URL=rediss://user:pass@host:6380/0?ssl_cert_reqs=required&ssl_ca_certs=/path/to/ca.pem
115 | ```
116 | 
117 | **AWS ElastiCache:**
118 | ```bash
119 | export REDIS_URL=redis://my-cluster.abc123.cache.amazonaws.com:6379/0
120 | ```
121 | 
122 | **Azure Cache for Redis:**
123 | ```bash
124 | export REDIS_URL=rediss://mycache.redis.cache.windows.net:6380/0?ssl_cert_reqs=required
125 | ```
126 | 
127 | ### Backward Compatibility: Individual Environment Variables
128 | 
129 | If `REDIS_URL` is not set, the extension will fall back to individual environment variables:
130 | 
131 | - `REDIS_HOST` - Redis hostname (default: 127.0.0.1)
132 | - `REDIS_PORT` - Redis port (default: 6379)
133 | - `REDIS_DB` - Database number (default: 0)
134 | - `REDIS_USERNAME` - Redis username (optional)
135 | - `REDIS_PWD` - Redis password (optional)
136 | - `REDIS_SSL` - Enable SSL: "true" or "false" (default: false)
137 | - `REDIS_SSL_CA_PATH` - Path to CA certificate file
138 | - `REDIS_SSL_KEYFILE` - Path to SSL key file
139 | - `REDIS_SSL_CERTFILE` - Path to SSL certificate file
140 | - `REDIS_SSL_CERT_REQS` - SSL certificate requirements (default: "required")
141 | - `REDIS_SSL_CA_CERTS` - Path to CA certificates file
142 | - `REDIS_CLUSTER_MODE` - Enable cluster mode: "true" or "false" (default: false)
143 | 
144 | **Example using individual variables:**
145 | ```bash
146 | export REDIS_HOST=my-redis-server.com
147 | export REDIS_PORT=6379
148 | export REDIS_PWD=mypassword
149 | export REDIS_SSL=true
150 | ```
151 | 
152 | ### Configuration Priority
153 | 
154 | 1. **`REDIS_URL`** (highest priority) - If set, this will be used exclusively
155 | 2. **Individual environment variables** - Used as fallback when `REDIS_URL` is not set
156 | 3. **Built-in defaults** - Used when no configuration is provided
157 | 
158 | ### Configuration Methods
159 | 
160 | 1. **Environment Variables**: Set variables in your shell or system
161 | 2. **`.env` File**: Create a `.env` file in your project directory
162 | 3. **System Environment**: Set variables at the system level
163 | 4. **Shell Profile**: Add exports to your `.bashrc`, `.zshrc`, etc.
164 | 
165 | ### No Configuration Required
166 | 
167 | If you don't set any configuration, the extension will automatically connect to a local Redis instance at `redis://127.0.0.1:6379/0`.
168 | 
169 | ### Advanced SSL Configuration
170 | 
171 | For production environments with custom SSL certificates, you can use query parameters in the Redis URL:
172 | 
173 | ```bash
174 | export REDIS_URL=rediss://user:pass@host:6380/0?ssl_cert_reqs=required&ssl_ca_path=/path/to/ca.pem&ssl_keyfile=/path/to/key.pem&ssl_certfile=/path/to/cert.pem
175 | ```
176 | 
177 | Supported SSL query parameters:
178 | - `ssl_cert_reqs` - Certificate requirements: "required", "optional", "none"
179 | - `ssl_ca_certs` - Path to CA certificates file
180 | - `ssl_ca_path` - Path to CA certificate file
181 | - `ssl_keyfile` - Path to SSL private key file
182 | - `ssl_certfile` - Path to SSL certificate file
183 | 
184 | For detailed configuration options and Redis URL format, see the main Redis MCP Server documentation.
185 | 
```

--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------

```yaml
  1 | name: CI
  2 | 
  3 | on:
  4 |   push:
  5 |     branches:
  6 |     - 'main'
  7 |     - '[0-9].[0-9]'
  8 |   pull_request:
  9 |     branches:
 10 |       - 'main'
 11 |       - '[0-9].[0-9]'
 12 |   schedule:
 13 |     - cron: '0 1 * * *' # nightly build
 14 | 
 15 | permissions:
 16 |   contents: read
 17 | 
 18 | jobs:
 19 |   lint-and-format:
 20 |     runs-on: ubuntu-latest
 21 |     steps:
 22 |     - name: ⚙️ Harden Runner
 23 |       uses: step-security/harden-runner@v2
 24 |       with:
 25 |         egress-policy: audit
 26 | 
 27 |     - name: ⚙️ Checkout the project
 28 |       uses: actions/checkout@v5
 29 | 
 30 |     - name: ⚙️ Install uv
 31 |       uses: astral-sh/setup-uv@v7
 32 |       with:
 33 |         version: "latest"
 34 |         enable-cache: false
 35 | 
 36 |     - name: ⚙️ Set Python up and add dependencies
 37 |       run: |
 38 |         uv python install 3.12
 39 |         uv sync --all-extras --dev
 40 |         uv add --dev ruff mypy
 41 | 
 42 |     - name: ⚙️ Run linters and formatters
 43 |       run: |
 44 |         uv run ruff check src/ tests/
 45 |         uv run ruff format --check src/ tests/
 46 |       # uv run mypy src/ --ignore-missing-imports
 47 | 
 48 | 
 49 |   security-scan:
 50 |     runs-on: ubuntu-latest
 51 |     steps:
 52 |     - name: ⚙️ Harden Runner
 53 |       uses: step-security/harden-runner@v2
 54 |       with:
 55 |         egress-policy: audit
 56 | 
 57 |     - name: ⚙️ Checkout the project
 58 |       uses: actions/checkout@v5
 59 | 
 60 |     - name: ⚙️ Install uv
 61 |       uses: astral-sh/setup-uv@v7
 62 |       with:
 63 |         version: "latest"
 64 |         enable-cache: false
 65 | 
 66 |     - name: ⚙️ Set Python up and add dependencies
 67 |       run: |
 68 |         uv python install 3.12
 69 |         uv sync --all-extras --dev
 70 |         uv add --dev bandit
 71 | 
 72 |     - name: ⚙️ Run security scan with bandit
 73 |       run: |
 74 |         uv run bandit -r src/ -f json -o bandit-report.json || true
 75 |         uv run bandit -r src/
 76 | 
 77 |     - name: ⚙️ Upload security reports
 78 |       uses: actions/upload-artifact@v5
 79 |       if: always()
 80 |       with:
 81 |         name: security-reports
 82 |         path: |
 83 |           bandit-report.json
 84 |         retention-days: 30
 85 | 
 86 | 
 87 |   test-ubuntu:
 88 |     runs-on: ubuntu-latest
 89 |     strategy:
 90 |       fail-fast: false
 91 |       matrix:
 92 |         python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
 93 | 
 94 |     services:
 95 |       redis:
 96 |         image: redis:latest
 97 |         ports:
 98 |           - 6379:6379
 99 |         options: >-
100 |           --health-cmd "redis-cli ping"
101 |           --health-interval 10s
102 |           --health-timeout 5s
103 |           --health-retries 5
104 | 
105 |     steps:
106 |     - name: ⚙️ Harden Runner
107 |       uses: step-security/harden-runner@v2
108 |       with:
109 |         egress-policy: audit
110 | 
111 |     - name: ⚙️ Checkout the project
112 |       uses: actions/checkout@v5
113 | 
114 |     - name: ⚙️ Install uv
115 |       uses: astral-sh/setup-uv@v7
116 |       with:
117 |         version: "latest"
118 |         enable-cache: false
119 | 
120 |     - name: ⚙️ Set Python ${{ matrix.python-version }} up and add dependencies
121 |       run: |
122 |         uv python install ${{ matrix.python-version }}
123 |         uv sync --all-extras --dev
124 |         uv add --dev pytest pytest-cov pytest-asyncio coverage
125 | 
126 |     - name: ⚙️ Run tests with coverage
127 |       run: |
128 |         uv run pytest tests/ -v --cov=src --cov-report=xml --cov-report=html --cov-report=term
129 |       env:
130 |         REDIS_HOST: localhost
131 |         REDIS_PORT: 6379
132 | 
133 |     - name: ⚙️ Test MCP server startup
134 |       run: |
135 |         timeout 10s uv run python src/main.py || test $? = 124
136 |       env:
137 |         REDIS_HOST: localhost
138 |         REDIS_PORT: 6379
139 | 
140 |     - name: ⚙️ Upload coverage reports
141 |       uses: codecov/codecov-action@v5
142 |       if: matrix.python-version == '3.12'
143 |       with:
144 |         files: ./coverage.xml
145 |         flags: unittests
146 |         name: codecov-umbrella
147 | 
148 |   test-other-os:
149 |     runs-on: ${{ matrix.os }}
150 |     strategy:
151 |       fail-fast: false
152 |       matrix:
153 |         os: [windows-latest, macos-latest]
154 |         python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
155 | 
156 |     steps:
157 |     - name: ⚙️ Checkout the project
158 |       uses: actions/checkout@v5
159 | 
160 |     - name: ⚙️ Install uv
161 |       uses: astral-sh/setup-uv@v7
162 |       with:
163 |         version: "latest"
164 |         enable-cache: false
165 | 
166 |     - name: ⚙️ Set Python ${{ matrix.python-version }} up and add dependencies
167 |       run: |
168 |         uv python install ${{ matrix.python-version }}
169 |         uv sync --all-extras --dev
170 |         uv add --dev pytest pytest-cov pytest-asyncio coverage
171 | 
172 |     - name: ⚙️ Run tests (without Redis services)
173 |       run: |
174 |         uv run pytest tests/ -v
175 |       env:
176 |         REDIS_HOST: localhost
177 |         REDIS_PORT: 6379
178 | 
179 |     - name: ⚙️ Test MCP server startup (macOS)
180 |       run: |
181 |         brew install coreutils
182 |         gtimeout 10s uv run python src/main.py || test $? = 124
183 |       env:
184 |         REDIS_HOST: localhost
185 |         REDIS_PORT: 6379
186 |       if: matrix.os == 'macos-latest'
187 | 
188 |     - name: ⚙️ Test MCP server startup (Windows)
189 |       run: |
190 |         Start-Process -FilePath "uv" -ArgumentList "run", "python", "src/main.py" -PassThru | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue
191 |       env:
192 |         REDIS_HOST: localhost
193 |         REDIS_PORT: 6379
194 |       if: matrix.os == 'windows-latest'
195 | 
196 | 
197 |   build-test:
198 |     runs-on: ubuntu-latest
199 |     needs: [lint-and-format, security-scan, test-ubuntu, test-other-os]
200 |     steps:
201 |     - name: ⚙️ Harden Runner
202 |       uses: step-security/harden-runner@v2
203 |       with:
204 |         egress-policy: audit
205 | 
206 |     - name: ⚙️ Checkout the project
207 |       uses: actions/checkout@v5
208 |       with:
209 |         fetch-depth: 0  # Full history for UV build
210 | 
211 |     - name: ⚙️ Install uv
212 |       uses: astral-sh/setup-uv@v7
213 |       with:
214 |         version: "latest"
215 |         enable-cache: false
216 | 
217 |     - name: ⚙️ Set up Python
218 |       run: uv python install 3.12
219 | 
220 |     - name: ⚙️ Build package
221 |       run: |
222 |         uv build --sdist --wheel
223 | 
224 |     - name: ⚙️ Check package
225 |       run: |
226 |         uv add --dev twine
227 |         uv run twine check dist/*
228 | 
229 |     - name: ⚙️ Test package installation
230 |       run: |
231 |         uv venv test-env
232 |         source test-env/bin/activate
233 |         pip install dist/*.whl
234 |         redis-mcp-server --help
235 | 
236 |     - name: ⚙️ Upload build artifacts
237 |       uses: actions/upload-artifact@v5
238 |       with:
239 |         name: dist-files
240 |         path: dist/
241 |         retention-days: 7
242 | 
243 | 
244 |   docker-test:
245 |     runs-on: ubuntu-latest
246 |     needs: [lint-and-format, security-scan]
247 |     steps:
248 |     - name: ⚙️ Harden Runner
249 |       uses: step-security/harden-runner@v2
250 |       with:
251 |         egress-policy: audit
252 | 
253 |     - name: ⚙️ Checkout the project
254 |       uses: actions/checkout@v5
255 | 
256 |     - name: ⚙️ Build Docker image
257 |       run: docker build -t redis-mcp-server:test .
258 | 
259 |     - name: ⚙️ Test Docker image
260 |       run: |
261 |         docker run --rm redis-mcp-server:test uv run python -c "import src.main; print('Docker build successful')"
262 | 
```

--------------------------------------------------------------------------------
/src/tools/misc.py:
--------------------------------------------------------------------------------

```python
  1 | from typing import Any, Dict, Union, List
  2 | 
  3 | from redis.exceptions import RedisError
  4 | 
  5 | from src.common.connection import RedisConnectionManager
  6 | from src.common.server import mcp
  7 | 
  8 | 
  9 | @mcp.tool()
 10 | async def delete(key: str) -> str:
 11 |     """Delete a Redis key.
 12 | 
 13 |     Args:
 14 |         key (str): The key to delete.
 15 | 
 16 |     Returns:
 17 |         str: Confirmation message or an error message.
 18 |     """
 19 |     try:
 20 |         r = RedisConnectionManager.get_connection()
 21 |         result = r.delete(key)
 22 |         return f"Successfully deleted {key}" if result else f"Key {key} not found"
 23 |     except RedisError as e:
 24 |         return f"Error deleting key {key}: {str(e)}"
 25 | 
 26 | 
 27 | @mcp.tool()
 28 | async def type(key: str) -> Dict[str, Any]:
 29 |     """Returns the string representation of the type of the value stored at key
 30 | 
 31 |     Args:
 32 |         key (str): The key to check.
 33 | 
 34 |     Returns:
 35 |         str: The type of key, or none when key doesn't exist
 36 |     """
 37 |     try:
 38 |         r = RedisConnectionManager.get_connection()
 39 |         key_type = r.type(key)
 40 |         info = {"key": key, "type": key_type, "ttl": r.ttl(key)}
 41 | 
 42 |         return info
 43 |     except RedisError as e:
 44 |         return {"error": str(e)}
 45 | 
 46 | 
 47 | @mcp.tool()
 48 | async def expire(name: str, expire_seconds: int) -> str:
 49 |     """Set an expiration time for a Redis key.
 50 | 
 51 |     Args:
 52 |         name: The Redis key.
 53 |         expire_seconds: Time in seconds after which the key should expire.
 54 | 
 55 |     Returns:
 56 |         A success message or an error message.
 57 |     """
 58 |     try:
 59 |         r = RedisConnectionManager.get_connection()
 60 |         success = r.expire(name, expire_seconds)
 61 |         return (
 62 |             f"Expiration set to {expire_seconds} seconds for '{name}'."
 63 |             if success
 64 |             else f"Key '{name}' does not exist."
 65 |         )
 66 |     except RedisError as e:
 67 |         return f"Error setting expiration for key '{name}': {str(e)}"
 68 | 
 69 | 
 70 | @mcp.tool()
 71 | async def rename(old_key: str, new_key: str) -> Dict[str, Any]:
 72 |     """
 73 |     Renames a Redis key from old_key to new_key.
 74 | 
 75 |     Args:
 76 |         old_key (str): The current name of the Redis key to rename.
 77 |         new_key (str): The new name to assign to the key.
 78 | 
 79 |     Returns:
 80 |         Dict[str, Any]: A dictionary containing the result of the operation.
 81 |             On success: {"status": "success", "message": "..."}
 82 |             On error: {"error": "..."}
 83 |     """
 84 |     try:
 85 |         r = RedisConnectionManager.get_connection()
 86 | 
 87 |         # Check if the old key exists
 88 |         if not r.exists(old_key):
 89 |             return {"error": f"Key '{old_key}' does not exist."}
 90 | 
 91 |         # Rename the key
 92 |         r.rename(old_key, new_key)
 93 |         return {
 94 |             "status": "success",
 95 |             "message": f"Renamed key '{old_key}' to '{new_key}'",
 96 |         }
 97 | 
 98 |     except RedisError as e:
 99 |         return {"error": str(e)}
100 | 
101 | 
102 | @mcp.tool()
103 | async def scan_keys(
104 |     pattern: str = "*", count: int = 100, cursor: int = 0
105 | ) -> Union[str, Dict[str, Any]]:
106 |     """
107 |     Scan keys in the Redis database using the SCAN command (non-blocking, production-safe).
108 | 
109 |     ⚠️  IMPORTANT: This returns PARTIAL results from one iteration. Use scan_all_keys()
110 |     to get ALL matching keys, or call this function multiple times with the returned cursor
111 |     until cursor becomes 0.
112 | 
113 |     The SCAN command iterates through the keyspace in small chunks, making it safe to use
114 |     on large databases without blocking other operations.
115 | 
116 |     Args:
117 |         pattern: Pattern to match keys against (default is "*" for all keys).
118 |                 Common patterns: "user:*", "cache:*", "*:123", etc.
119 |         count: Hint for the number of keys to return per iteration (default 100).
120 |                Redis may return more or fewer keys than this hint.
121 |         cursor: The cursor position to start scanning from (0 to start from beginning).
122 |                 To continue scanning, use the cursor value returned from previous call.
123 | 
124 |     Returns:
125 |         A dictionary containing:
126 |         - 'cursor': Next cursor position (0 means scan is complete)
127 |         - 'keys': List of keys found in this iteration (PARTIAL RESULTS)
128 |         - 'total_scanned': Number of keys returned in this batch
129 |         - 'scan_complete': Boolean indicating if scan is finished
130 |         Or an error message if something goes wrong.
131 | 
132 |     Example usage:
133 |         First call: scan_keys("user:*") -> returns cursor=1234, keys=[...], scan_complete=False
134 |         Next call: scan_keys("user:*", cursor=1234) -> continues from where it left off
135 |         Final call: returns cursor=0, scan_complete=True when done
136 |     """
137 |     try:
138 |         r = RedisConnectionManager.get_connection()
139 |         cursor, keys = r.scan(cursor=cursor, match=pattern, count=count)
140 | 
141 |         # Convert bytes to strings if needed
142 |         decoded_keys = [
143 |             key.decode("utf-8") if isinstance(key, bytes) else key for key in keys
144 |         ]
145 | 
146 |         return {
147 |             "cursor": cursor,
148 |             "keys": decoded_keys,
149 |             "total_scanned": len(decoded_keys),
150 |             "scan_complete": cursor == 0,
151 |         }
152 |     except RedisError as e:
153 |         return f"Error scanning keys with pattern '{pattern}': {str(e)}"
154 | 
155 | 
156 | @mcp.tool()
157 | async def scan_all_keys(
158 |     pattern: str = "*", batch_size: int = 100
159 | ) -> Union[str, List[str]]:
160 |     """
161 |     Scan and return ALL keys matching a pattern using multiple SCAN iterations.
162 | 
163 |     This function automatically handles the SCAN cursor iteration to collect all matching keys.
164 |     It's safer than KEYS * for large databases but will still collect all results in memory.
165 | 
166 |     ⚠️  WARNING: With very large datasets (millions of keys), this may consume significant memory.
167 |     For large-scale operations, consider using scan_keys() with manual iteration instead.
168 | 
169 |     Args:
170 |         pattern: Pattern to match keys against (default is "*" for all keys).
171 |         batch_size: Number of keys to scan per iteration (default 100).
172 | 
173 |     Returns:
174 |         A list of all keys matching the pattern or an error message.
175 |     """
176 |     try:
177 |         r = RedisConnectionManager.get_connection()
178 |         all_keys = []
179 |         cursor = 0
180 | 
181 |         while True:
182 |             cursor, keys = r.scan(cursor=cursor, match=pattern, count=batch_size)
183 | 
184 |             # Convert bytes to strings if needed and add to results
185 |             decoded_keys = [
186 |                 key.decode("utf-8") if isinstance(key, bytes) else key for key in keys
187 |             ]
188 |             all_keys.extend(decoded_keys)
189 | 
190 |             # Break when scan is complete (cursor returns to 0)
191 |             if cursor == 0:
192 |                 break
193 | 
194 |         return all_keys
195 |     except RedisError as e:
196 |         return f"Error scanning all keys with pattern '{pattern}': {str(e)}"
197 | 
```

--------------------------------------------------------------------------------
/tests/tools/test_string.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Unit tests for src/tools/string.py
  3 | """
  4 | 
  5 | from unittest.mock import Mock, patch
  6 | 
  7 | import pytest
  8 | from redis.exceptions import ConnectionError, RedisError, TimeoutError
  9 | 
 10 | from src.tools.string import get, set
 11 | 
 12 | 
 13 | class TestStringOperations:
 14 |     """Test cases for Redis string operations."""
 15 | 
 16 |     @pytest.mark.asyncio
 17 |     async def test_set_success(self, mock_redis_connection_manager):
 18 |         """Test successful string set operation."""
 19 |         mock_redis = mock_redis_connection_manager
 20 |         mock_redis.set.return_value = True
 21 | 
 22 |         result = await set("test_key", "test_value")
 23 | 
 24 |         mock_redis.set.assert_called_once_with("test_key", b"test_value")
 25 |         assert "Successfully set test_key" in result
 26 | 
 27 |     @pytest.mark.asyncio
 28 |     async def test_set_with_expiration(self, mock_redis_connection_manager):
 29 |         """Test string set operation with expiration."""
 30 |         mock_redis = mock_redis_connection_manager
 31 |         mock_redis.setex.return_value = True
 32 | 
 33 |         result = await set("test_key", "test_value", 60)
 34 | 
 35 |         mock_redis.setex.assert_called_once_with("test_key", 60, b"test_value")
 36 |         assert "Successfully set test_key" in result
 37 |         assert "with expiration 60 seconds" in result
 38 | 
 39 |     @pytest.mark.asyncio
 40 |     async def test_set_redis_error(self, mock_redis_connection_manager):
 41 |         """Test string set operation with Redis error."""
 42 |         mock_redis = mock_redis_connection_manager
 43 |         mock_redis.set.side_effect = RedisError("Connection failed")
 44 | 
 45 |         result = await set("test_key", "test_value")
 46 | 
 47 |         assert "Error setting key test_key: Connection failed" in result
 48 | 
 49 |     @pytest.mark.asyncio
 50 |     async def test_set_connection_error(self, mock_redis_connection_manager):
 51 |         """Test string set operation with connection error."""
 52 |         mock_redis = mock_redis_connection_manager
 53 |         mock_redis.set.side_effect = ConnectionError("Redis server unavailable")
 54 | 
 55 |         result = await set("test_key", "test_value")
 56 | 
 57 |         assert "Error setting key test_key: Redis server unavailable" in result
 58 | 
 59 |     @pytest.mark.asyncio
 60 |     async def test_set_timeout_error(self, mock_redis_connection_manager):
 61 |         """Test string set operation with timeout error."""
 62 |         mock_redis = mock_redis_connection_manager
 63 |         mock_redis.setex.side_effect = TimeoutError("Operation timed out")
 64 | 
 65 |         result = await set("test_key", "test_value", 30)
 66 | 
 67 |         assert "Error setting key test_key: Operation timed out" in result
 68 | 
 69 |     @pytest.mark.asyncio
 70 |     async def test_get_success(self, mock_redis_connection_manager):
 71 |         """Test successful string get operation."""
 72 |         mock_redis = mock_redis_connection_manager
 73 |         mock_redis.get.return_value = "test_value"
 74 | 
 75 |         result = await get("test_key")
 76 | 
 77 |         mock_redis.get.assert_called_once_with("test_key")
 78 |         assert result == "test_value"
 79 | 
 80 |     @pytest.mark.asyncio
 81 |     async def test_get_key_not_found(self, mock_redis_connection_manager):
 82 |         """Test string get operation when key doesn't exist."""
 83 |         mock_redis = mock_redis_connection_manager
 84 |         mock_redis.get.return_value = None
 85 | 
 86 |         result = await get("nonexistent_key")
 87 | 
 88 |         mock_redis.get.assert_called_once_with("nonexistent_key")
 89 |         assert "Key nonexistent_key does not exist" in result
 90 | 
 91 |     @pytest.mark.asyncio
 92 |     async def test_get_redis_error(self, mock_redis_connection_manager):
 93 |         """Test string get operation with Redis error."""
 94 |         mock_redis = mock_redis_connection_manager
 95 |         mock_redis.get.side_effect = RedisError("Connection failed")
 96 | 
 97 |         result = await get("test_key")
 98 | 
 99 |         assert "Error retrieving key test_key: Connection failed" in result
100 | 
101 |     @pytest.mark.asyncio
102 |     async def test_get_empty_string_value(self, mock_redis_connection_manager):
103 |         """Test string get operation returning empty string."""
104 |         mock_redis = mock_redis_connection_manager
105 |         mock_redis.get.return_value = b""  # Redis returns bytes
106 | 
107 |         result = await get("test_key")
108 | 
109 |         # The implementation correctly handles empty bytes and returns empty string
110 |         assert result == ""
111 | 
112 |     @pytest.mark.asyncio
113 |     async def test_set_with_zero_expiration(self, mock_redis_connection_manager):
114 |         """Test string set operation with zero expiration."""
115 |         mock_redis = mock_redis_connection_manager
116 |         mock_redis.set.return_value = True
117 | 
118 |         result = await set("test_key", "test_value", 0)
119 | 
120 |         # Should use regular set, not setex for zero expiration
121 |         mock_redis.set.assert_called_once_with("test_key", b"test_value")
122 |         assert "Successfully set test_key" in result
123 | 
124 |     @pytest.mark.asyncio
125 |     async def test_set_with_negative_expiration(self, mock_redis_connection_manager):
126 |         """Test string set operation with negative expiration."""
127 |         mock_redis = mock_redis_connection_manager
128 |         mock_redis.setex.return_value = True
129 | 
130 |         result = await set("test_key", "test_value", -1)
131 | 
132 |         # Negative expiration is truthy in Python, so setex is called
133 |         mock_redis.setex.assert_called_once_with("test_key", -1, b"test_value")
134 |         assert "Successfully set test_key" in result
135 |         assert "with expiration -1 seconds" in result
136 | 
137 |     @pytest.mark.asyncio
138 |     async def test_set_with_large_expiration(self, mock_redis_connection_manager):
139 |         """Test string set operation with large expiration value."""
140 |         mock_redis = mock_redis_connection_manager
141 |         mock_redis.setex.return_value = True
142 | 
143 |         result = await set("test_key", "test_value", 86400)  # 24 hours
144 | 
145 |         mock_redis.setex.assert_called_once_with("test_key", 86400, b"test_value")
146 |         assert "with expiration 86400 seconds" in result
147 | 
148 |     @pytest.mark.asyncio
149 |     async def test_get_with_special_characters(self, mock_redis_connection_manager):
150 |         """Test string get operation with special characters in key."""
151 |         mock_redis = mock_redis_connection_manager
152 |         mock_redis.get.return_value = "special_value"
153 | 
154 |         special_key = "test:key:with:colons"
155 |         result = await get(special_key)
156 | 
157 |         mock_redis.get.assert_called_once_with(special_key)
158 |         assert result == "special_value"
159 | 
160 |     @pytest.mark.asyncio
161 |     async def test_set_with_unicode_value(self, mock_redis_connection_manager):
162 |         """Test string set operation with unicode value."""
163 |         mock_redis = mock_redis_connection_manager
164 |         mock_redis.set.return_value = True
165 | 
166 |         unicode_value = "测试值 🚀"
167 |         result = await set("test_key", unicode_value)
168 | 
169 |         mock_redis.set.assert_called_once_with(
170 |             "test_key", unicode_value.encode("utf-8")
171 |         )
172 |         assert "Successfully set test_key" in result
173 | 
174 |     @pytest.mark.asyncio
175 |     async def test_connection_manager_called_correctly(self):
176 |         """Test that RedisConnectionManager.get_connection is called correctly."""
177 |         with patch(
178 |             "src.tools.string.RedisConnectionManager.get_connection"
179 |         ) as mock_get_conn:
180 |             mock_redis = Mock()
181 |             mock_redis.set.return_value = True
182 |             mock_get_conn.return_value = mock_redis
183 | 
184 |             await set("test_key", "test_value")
185 | 
186 |             mock_get_conn.assert_called_once()
187 |             # Verify the actual call was made with bytes
188 |             mock_redis.set.assert_called_once_with("test_key", b"test_value")
189 | 
190 |     @pytest.mark.asyncio
191 |     async def test_function_signatures(self):
192 |         """Test that functions have correct signatures."""
193 |         import inspect
194 | 
195 |         # Test set function signature
196 |         set_sig = inspect.signature(set)
197 |         set_params = list(set_sig.parameters.keys())
198 |         assert set_params == ["key", "value", "expiration"]
199 |         assert set_sig.parameters["expiration"].default is None
200 | 
201 |         # Test get function signature
202 |         get_sig = inspect.signature(get)
203 |         get_params = list(get_sig.parameters.keys())
204 |         assert get_params == ["key"]
205 | 
```

--------------------------------------------------------------------------------
/src/common/config.py:
--------------------------------------------------------------------------------

```python
  1 | import os
  2 | import urllib.parse
  3 | 
  4 | from dotenv import load_dotenv
  5 | 
  6 | load_dotenv()
  7 | 
  8 | # Default values for Entra ID authentication
  9 | DEFAULT_TOKEN_EXPIRATION_REFRESH_RATIO = 0.9
 10 | DEFAULT_LOWER_REFRESH_BOUND_MILLIS = 30000  # 30 seconds
 11 | DEFAULT_TOKEN_REQUEST_EXECUTION_TIMEOUT_MS = 10000  # 10 seconds
 12 | DEFAULT_RETRY_MAX_ATTEMPTS = 3
 13 | DEFAULT_RETRY_DELAY_MS = 100
 14 | 
 15 | REDIS_CFG = {
 16 |     "host": os.getenv("REDIS_HOST", "127.0.0.1"),
 17 |     "port": int(os.getenv("REDIS_PORT", 6379)),
 18 |     "username": os.getenv("REDIS_USERNAME", None),
 19 |     "password": os.getenv("REDIS_PWD", ""),
 20 |     "ssl": os.getenv("REDIS_SSL", False) in ("true", "1", "t"),
 21 |     "ssl_ca_path": os.getenv("REDIS_SSL_CA_PATH", None),
 22 |     "ssl_keyfile": os.getenv("REDIS_SSL_KEYFILE", None),
 23 |     "ssl_certfile": os.getenv("REDIS_SSL_CERTFILE", None),
 24 |     "ssl_cert_reqs": os.getenv("REDIS_SSL_CERT_REQS", "required"),
 25 |     "ssl_ca_certs": os.getenv("REDIS_SSL_CA_CERTS", None),
 26 |     "cluster_mode": os.getenv("REDIS_CLUSTER_MODE", False) in ("true", "1", "t"),
 27 |     "db": int(os.getenv("REDIS_DB", 0)),
 28 | }
 29 | 
 30 | # Entra ID Authentication Configuration
 31 | ENTRAID_CFG = {
 32 |     # Authentication flow selection
 33 |     "auth_flow": os.getenv(
 34 |         "REDIS_ENTRAID_AUTH_FLOW", None
 35 |     ),  # service_principal, managed_identity, default_credential
 36 |     # Service Principal Authentication
 37 |     "client_id": os.getenv("REDIS_ENTRAID_CLIENT_ID", None),
 38 |     "client_secret": os.getenv("REDIS_ENTRAID_CLIENT_SECRET", None),
 39 |     "tenant_id": os.getenv("REDIS_ENTRAID_TENANT_ID", None),
 40 |     # Managed Identity Authentication
 41 |     "identity_type": os.getenv(
 42 |         "REDIS_ENTRAID_IDENTITY_TYPE", "system_assigned"
 43 |     ),  # system_assigned, user_assigned
 44 |     "user_assigned_identity_client_id": os.getenv(
 45 |         "REDIS_ENTRAID_USER_ASSIGNED_CLIENT_ID", None
 46 |     ),
 47 |     # Default Azure Credential Authentication
 48 |     "scopes": os.getenv("REDIS_ENTRAID_SCOPES", "https://redis.azure.com/.default"),
 49 |     # Token lifecycle configuration
 50 |     "token_expiration_refresh_ratio": float(
 51 |         os.getenv(
 52 |             "REDIS_ENTRAID_TOKEN_EXPIRATION_REFRESH_RATIO",
 53 |             DEFAULT_TOKEN_EXPIRATION_REFRESH_RATIO,
 54 |         )
 55 |     ),
 56 |     "lower_refresh_bound_millis": int(
 57 |         os.getenv(
 58 |             "REDIS_ENTRAID_LOWER_REFRESH_BOUND_MILLIS",
 59 |             DEFAULT_LOWER_REFRESH_BOUND_MILLIS,
 60 |         )
 61 |     ),
 62 |     "token_request_execution_timeout_ms": int(
 63 |         os.getenv(
 64 |             "REDIS_ENTRAID_TOKEN_REQUEST_EXECUTION_TIMEOUT_MS",
 65 |             DEFAULT_TOKEN_REQUEST_EXECUTION_TIMEOUT_MS,
 66 |         )
 67 |     ),
 68 |     # Retry configuration
 69 |     "retry_max_attempts": int(
 70 |         os.getenv("REDIS_ENTRAID_RETRY_MAX_ATTEMPTS", DEFAULT_RETRY_MAX_ATTEMPTS)
 71 |     ),
 72 |     "retry_delay_ms": int(
 73 |         os.getenv("REDIS_ENTRAID_RETRY_DELAY_MS", DEFAULT_RETRY_DELAY_MS)
 74 |     ),
 75 |     # Resource configuration
 76 |     "resource": os.getenv("REDIS_ENTRAID_RESOURCE", "https://redis.azure.com/"),
 77 | }
 78 | 
 79 | 
 80 | def parse_redis_uri(uri: str) -> dict:
 81 |     """Parse a Redis URI and return connection parameters."""
 82 |     parsed = urllib.parse.urlparse(uri)
 83 | 
 84 |     config = {}
 85 | 
 86 |     # Scheme determines SSL
 87 |     if parsed.scheme == "rediss":
 88 |         config["ssl"] = True
 89 |     elif parsed.scheme == "redis":
 90 |         config["ssl"] = False
 91 |     else:
 92 |         raise ValueError(f"Unsupported scheme: {parsed.scheme}")
 93 | 
 94 |     # Host and port
 95 |     config["host"] = parsed.hostname or "127.0.0.1"
 96 |     config["port"] = parsed.port or 6379
 97 | 
 98 |     # Database
 99 |     if parsed.path and parsed.path != "/":
100 |         try:
101 |             config["db"] = int(parsed.path.lstrip("/"))
102 |         except ValueError:
103 |             config["db"] = 0
104 |     else:
105 |         config["db"] = 0
106 | 
107 |     # Authentication
108 |     if parsed.username:
109 |         config["username"] = parsed.username
110 |     if parsed.password:
111 |         config["password"] = parsed.password
112 | 
113 |     # Parse query parameters for SSL and other options
114 |     if parsed.query:
115 |         query_params = urllib.parse.parse_qs(parsed.query)
116 | 
117 |         # Handle SSL parameters
118 |         if "ssl_cert_reqs" in query_params:
119 |             config["ssl_cert_reqs"] = query_params["ssl_cert_reqs"][0]
120 |         if "ssl_ca_certs" in query_params:
121 |             config["ssl_ca_certs"] = query_params["ssl_ca_certs"][0]
122 |         if "ssl_ca_path" in query_params:
123 |             config["ssl_ca_path"] = query_params["ssl_ca_path"][0]
124 |         if "ssl_keyfile" in query_params:
125 |             config["ssl_keyfile"] = query_params["ssl_keyfile"][0]
126 |         if "ssl_certfile" in query_params:
127 |             config["ssl_certfile"] = query_params["ssl_certfile"][0]
128 | 
129 |         # Handle other parameters. According to https://www.iana.org/assignments/uri-schemes/prov/redis,
130 |         # The database number to use for the Redis SELECT command comes from
131 |         #   either the "db-number" portion of the URI (described in the previous
132 |         #   section) or the value from the key-value pair from the "query" URI
133 |         #   field with the key "db".  If neither of these are present, the
134 |         #   default database number is 0.
135 |         if "db" in query_params:
136 |             try:
137 |                 config["db"] = int(query_params["db"][0])
138 |             except ValueError:
139 |                 pass
140 | 
141 |     return config
142 | 
143 | 
144 | def set_redis_config_from_cli(config: dict):
145 |     for key, value in config.items():
146 |         if key in ["port", "db"]:
147 |             # Keep port and db as integers
148 |             REDIS_CFG[key] = int(value)
149 |         elif key == "ssl" or key == "cluster_mode":
150 |             # Keep ssl and cluster_mode as booleans
151 |             REDIS_CFG[key] = bool(value)
152 |         elif isinstance(value, bool):
153 |             # Convert other booleans to strings for environment compatibility
154 |             REDIS_CFG[key] = "true" if value else "false"
155 |         else:
156 |             # Convert other values to strings
157 |             REDIS_CFG[key] = str(value) if value is not None else None
158 | 
159 | 
160 | def set_entraid_config_from_cli(config: dict):
161 |     """Update Entra ID configuration from CLI parameters."""
162 |     for key, value in config.items():
163 |         if value is not None:
164 |             if key in ["token_expiration_refresh_ratio"]:
165 |                 # Keep float values as floats
166 |                 ENTRAID_CFG[key] = float(value)
167 |             elif key in [
168 |                 "lower_refresh_bound_millis",
169 |                 "token_request_execution_timeout_ms",
170 |                 "retry_max_attempts",
171 |                 "retry_delay_ms",
172 |             ]:
173 |                 # Keep integer values as integers
174 |                 ENTRAID_CFG[key] = int(value)
175 |             else:
176 |                 # Convert other values to strings
177 |                 ENTRAID_CFG[key] = str(value)
178 | 
179 | 
180 | def is_entraid_auth_enabled() -> bool:
181 |     """Check if Entra ID authentication is enabled."""
182 |     return ENTRAID_CFG["auth_flow"] is not None
183 | 
184 | 
185 | def get_entraid_auth_flow() -> str:
186 |     """Get the configured Entra ID authentication flow."""
187 |     return ENTRAID_CFG["auth_flow"]
188 | 
189 | 
190 | def validate_entraid_config() -> tuple[bool, str]:
191 |     """Validate Entra ID configuration based on the selected auth flow.
192 | 
193 |     Returns:
194 |         tuple: (is_valid, error_message)
195 |     """
196 |     auth_flow = ENTRAID_CFG["auth_flow"]
197 | 
198 |     if not auth_flow:
199 |         return True, ""  # No Entra ID auth configured, which is valid
200 | 
201 |     if auth_flow == "service_principal":
202 |         required_fields = ["client_id", "client_secret", "tenant_id"]
203 |         missing_fields = [field for field in required_fields if not ENTRAID_CFG[field]]
204 |         if missing_fields:
205 |             return (
206 |                 False,
207 |                 f"Service principal authentication requires: {', '.join(missing_fields)}",
208 |             )
209 | 
210 |     elif auth_flow == "managed_identity":
211 |         identity_type = ENTRAID_CFG["identity_type"]
212 |         if (
213 |             identity_type == "user_assigned"
214 |             and not ENTRAID_CFG["user_assigned_identity_client_id"]
215 |         ):
216 |             return (
217 |                 False,
218 |                 "User-assigned managed identity requires user_assigned_identity_client_id",
219 |             )
220 | 
221 |     elif auth_flow == "default_credential":
222 |         # Default credential doesn't require specific configuration
223 |         pass
224 | 
225 |     else:
226 |         return (
227 |             False,
228 |             f"Invalid auth_flow: {auth_flow}. Must be one of: service_principal, managed_identity, default_credential",
229 |         )
230 | 
231 |     return True, ""
232 | 
```

--------------------------------------------------------------------------------
/tests/test_main.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Unit tests for src/main.py
  3 | """
  4 | 
  5 | import logging
  6 | 
  7 | from unittest.mock import Mock, patch
  8 | 
  9 | import pytest
 10 | from click.testing import CliRunner
 11 | 
 12 | from src.main import RedisMCPServer, cli
 13 | 
 14 | 
 15 | class TestRedisMCPServer:
 16 |     """Test cases for RedisMCPServer class."""
 17 | 
 18 |     def test_init_logs_startup_message(self, capsys, caplog, monkeypatch):
 19 |         """Startup should emit an INFO log; client may route it via handlers.
 20 |         Accept either stderr output or log record text.
 21 |         """
 22 |         monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "INFO")
 23 | 
 24 |         with caplog.at_level(logging.INFO):
 25 |             server = RedisMCPServer()
 26 |             assert server is not None
 27 | 
 28 |         captured = capsys.readouterr()
 29 |         stderr_text = captured.err or ""
 30 |         log_text = caplog.text or ""  # collected by pytest logging handler
 31 |         combined = stderr_text + "\n" + log_text
 32 |         assert "Starting the Redis MCP Server" in combined
 33 | 
 34 |     @patch("src.main.mcp.run")
 35 |     def test_run_calls_mcp_run(self, mock_mcp_run):
 36 |         """Test that RedisMCPServer.run() calls mcp.run()."""
 37 |         server = RedisMCPServer()
 38 |         server.run()
 39 |         mock_mcp_run.assert_called_once()
 40 | 
 41 |     @patch("src.main.mcp.run")
 42 |     def test_run_propagates_exceptions(self, mock_mcp_run):
 43 |         """Test that exceptions from mcp.run() are propagated."""
 44 |         mock_mcp_run.side_effect = Exception("MCP run failed")
 45 |         server = RedisMCPServer()
 46 | 
 47 |         with pytest.raises(Exception, match="MCP run failed"):
 48 |             server.run()
 49 | 
 50 | 
 51 | class TestCLI:
 52 |     """Test cases for CLI interface."""
 53 | 
 54 |     def setup_method(self):
 55 |         """Set up test fixtures."""
 56 |         self.runner = CliRunner()
 57 | 
 58 |     @patch("src.main.parse_redis_uri")
 59 |     @patch("src.main.set_redis_config_from_cli")
 60 |     @patch("src.main.RedisMCPServer")
 61 |     def test_cli_with_url_parameter(
 62 |         self, mock_server_class, mock_set_config, mock_parse_uri
 63 |     ):
 64 |         """Test CLI with --url parameter."""
 65 |         mock_parse_uri.return_value = {"host": "localhost", "port": 6379}
 66 |         mock_server = Mock()
 67 |         mock_server_class.return_value = mock_server
 68 | 
 69 |         result = self.runner.invoke(cli, ["--url", "redis://localhost:6379/0"])
 70 | 
 71 |         assert result.exit_code == 0
 72 |         mock_parse_uri.assert_called_once_with("redis://localhost:6379/0")
 73 |         mock_set_config.assert_called_once_with({"host": "localhost", "port": 6379})
 74 |         mock_server_class.assert_called_once()
 75 |         mock_server.run.assert_called_once()
 76 | 
 77 |     @patch("src.main.set_redis_config_from_cli")
 78 |     @patch("src.main.RedisMCPServer")
 79 |     def test_cli_with_individual_parameters(self, mock_server_class, mock_set_config):
 80 |         """Test CLI with individual connection parameters."""
 81 |         mock_server = Mock()
 82 |         mock_server_class.return_value = mock_server
 83 | 
 84 |         result = self.runner.invoke(
 85 |             cli,
 86 |             [
 87 |                 "--host",
 88 |                 "redis.example.com",
 89 |                 "--port",
 90 |                 "6380",
 91 |                 "--db",
 92 |                 "1",
 93 |                 "--username",
 94 |                 "testuser",
 95 |                 "--password",
 96 |                 "testpass",
 97 |                 "--ssl",
 98 |             ],
 99 |         )
100 | 
101 |         assert result.exit_code == 0
102 |         mock_set_config.assert_called_once()
103 | 
104 |         # Verify the config passed to set_redis_config_from_cli
105 |         call_args = mock_set_config.call_args[0][0]
106 |         assert call_args["host"] == "redis.example.com"
107 |         assert call_args["port"] == 6380
108 |         assert call_args["db"] == 1
109 |         assert call_args["username"] == "testuser"
110 |         assert call_args["password"] == "testpass"
111 |         assert call_args["ssl"] is True
112 | 
113 |     @patch("src.main.set_redis_config_from_cli")
114 |     @patch("src.main.RedisMCPServer")
115 |     def test_cli_with_ssl_parameters(self, mock_server_class, mock_set_config):
116 |         """Test CLI with SSL-specific parameters."""
117 |         mock_server = Mock()
118 |         mock_server_class.return_value = mock_server
119 | 
120 |         result = self.runner.invoke(
121 |             cli,
122 |             [
123 |                 "--ssl",
124 |                 "--ssl-ca-path",
125 |                 "/path/to/ca.pem",
126 |                 "--ssl-keyfile",
127 |                 "/path/to/key.pem",
128 |                 "--ssl-certfile",
129 |                 "/path/to/cert.pem",
130 |                 "--ssl-cert-reqs",
131 |                 "optional",
132 |                 "--ssl-ca-certs",
133 |                 "/path/to/ca-bundle.pem",
134 |             ],
135 |         )
136 | 
137 |         assert result.exit_code == 0
138 |         call_args = mock_set_config.call_args[0][0]
139 |         assert call_args["ssl"] is True
140 |         assert call_args["ssl_ca_path"] == "/path/to/ca.pem"
141 |         assert call_args["ssl_keyfile"] == "/path/to/key.pem"
142 |         assert call_args["ssl_certfile"] == "/path/to/cert.pem"
143 |         assert call_args["ssl_cert_reqs"] == "optional"
144 |         assert call_args["ssl_ca_certs"] == "/path/to/ca-bundle.pem"
145 | 
146 |     @patch("src.main.set_redis_config_from_cli")
147 |     @patch("src.main.RedisMCPServer")
148 |     def test_cli_with_cluster_mode(self, mock_server_class, mock_set_config):
149 |         """Test CLI with cluster mode enabled."""
150 |         mock_server = Mock()
151 |         mock_server_class.return_value = mock_server
152 | 
153 |         result = self.runner.invoke(cli, ["--cluster-mode"])
154 | 
155 |         assert result.exit_code == 0
156 |         call_args = mock_set_config.call_args[0][0]
157 |         assert call_args["cluster_mode"] is True
158 | 
159 |     @patch("src.main.parse_redis_uri")
160 |     def test_cli_with_invalid_url(self, mock_parse_uri):
161 |         """Test CLI with invalid Redis URL."""
162 |         mock_parse_uri.side_effect = ValueError("Invalid Redis URI")
163 | 
164 |         result = self.runner.invoke(cli, ["--url", "invalid://url"])
165 | 
166 |         assert result.exit_code != 0
167 |         assert "Invalid Redis URI" in result.output
168 | 
169 |     @patch("src.main.RedisMCPServer")
170 |     def test_cli_server_initialization_failure(self, mock_server_class):
171 |         """Test CLI when server initialization fails."""
172 |         mock_server_class.side_effect = Exception("Server init failed")
173 | 
174 |         result = self.runner.invoke(cli, [])
175 | 
176 |         assert result.exit_code != 0
177 | 
178 |     @patch("src.main.RedisMCPServer")
179 |     def test_cli_server_run_failure(self, mock_server_class):
180 |         """Test CLI when server run fails."""
181 |         mock_server = Mock()
182 |         mock_server.run.side_effect = Exception("Server run failed")
183 |         mock_server_class.return_value = mock_server
184 | 
185 |         result = self.runner.invoke(cli, [])
186 | 
187 |         assert result.exit_code != 0
188 | 
189 |     def test_cli_help(self):
190 |         """Test CLI help output."""
191 |         result = self.runner.invoke(cli, ["--help"])
192 | 
193 |         assert result.exit_code == 0
194 |         assert "Redis connection URI" in result.output
195 |         assert "--host" in result.output
196 |         assert "--port" in result.output
197 |         assert "--ssl" in result.output
198 | 
199 |     @patch("src.main.set_redis_config_from_cli")
200 |     @patch("src.main.RedisMCPServer")
201 |     def test_cli_default_values(self, mock_server_class, mock_set_config):
202 |         """Test CLI with default values."""
203 |         mock_server = Mock()
204 |         mock_server_class.return_value = mock_server
205 | 
206 |         result = self.runner.invoke(cli, [])
207 | 
208 |         assert result.exit_code == 0
209 |         # Should be called with empty config when no parameters provided
210 |         mock_set_config.assert_called_once()
211 |         call_args = mock_set_config.call_args[0][0]
212 | 
213 |         # Check that only non-None values are in the config
214 |         for key, value in call_args.items():
215 |             if value is not None:
216 |                 # These should be the default values or explicitly set values
217 |                 assert isinstance(value, (str, int, bool))
218 | 
219 |     @patch("src.main.parse_redis_uri")
220 |     @patch("src.main.set_redis_config_from_cli")
221 |     @patch("src.main.RedisMCPServer")
222 |     def test_cli_url_overrides_individual_params(
223 |         self, mock_server_class, mock_set_config, mock_parse_uri
224 |     ):
225 |         """Test that --url parameter takes precedence over individual parameters."""
226 |         mock_parse_uri.return_value = {"host": "uri-host", "port": 9999}
227 |         mock_server = Mock()
228 |         mock_server_class.return_value = mock_server
229 | 
230 |         result = self.runner.invoke(
231 |             cli,
232 |             [
233 |                 "--url",
234 |                 "redis://uri-host:9999/0",
235 |                 "--host",
236 |                 "individual-host",
237 |                 "--port",
238 |                 "6379",
239 |             ],
240 |         )
241 | 
242 |         assert result.exit_code == 0
243 |         mock_parse_uri.assert_called_once_with("redis://uri-host:9999/0")
244 |         # Should use URI config, not individual parameters
245 |         call_args = mock_set_config.call_args[0][0]
246 |         assert call_args["host"] == "uri-host"
247 |         assert call_args["port"] == 9999
248 | 
```

--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------

```yaml
  1 |   name: Release to PyPI
  2 |   
  3 |   on:
  4 |     release:
  5 |       types: [published]
  6 |     workflow_dispatch:
  7 |       inputs:
  8 |         version:
  9 |           description: 'Version to release (e.g., 0.3.0)'
 10 |           required: true
 11 |           type: string
 12 |         environment:
 13 |           description: 'Target environment'
 14 |           required: true
 15 |           default: 'pypi'
 16 |           type: choice
 17 |           options:
 18 |           - pypi
 19 |           - testpypi
 20 |         dry_run:
 21 |           description: 'Dry run (build only, do not publish)'
 22 |           required: false
 23 |           default: false
 24 |           type: boolean
 25 |   
 26 |   permissions:
 27 |     contents: read
 28 |   
 29 |   jobs:
 30 |     validate-release:
 31 |       runs-on: ubuntu-latest
 32 |       outputs:
 33 |         version: ${{ steps.get-version.outputs.version }}
 34 |         tag-version: ${{ steps.get-version.outputs.tag-version }}
 35 |       steps:
 36 |       - name: ⚙️ Harden Runner
 37 |         uses: step-security/harden-runner@v2
 38 |         with:
 39 |           egress-policy: audit
 40 |   
 41 |       - name: ⚙️ Checkout the project
 42 |         uses: actions/checkout@v5
 43 |         with:
 44 |           fetch-depth: 0  # Full history for UV build
 45 |   
 46 |       - name: ⚙️ Install uv
 47 |         uses: astral-sh/setup-uv@v7
 48 |         with:
 49 |           version: "latest"
 50 |           enable-cache: false
 51 |   
 52 |       - name: ⚙️ Set up Python
 53 |         run: uv python install 3.12
 54 |   
 55 |     security-scan:
 56 |       runs-on: ubuntu-latest
 57 |       needs: validate-release
 58 |       steps:
 59 |       - name: ⚙️ Harden Runner
 60 |         uses: step-security/harden-runner@v2
 61 |         with:
 62 |           egress-policy: audit
 63 |   
 64 |       - name: ⚙️ Checkout the project
 65 |         uses: actions/checkout@v5
 66 |         with:
 67 |           fetch-depth: 0
 68 |   
 69 |       - name: ⚙️ Install uv
 70 |         uses: astral-sh/setup-uv@v7
 71 |         with:
 72 |           version: "latest"
 73 |           enable-cache: false
 74 |   
 75 |       - name: ⚙️ Set Python up and add dependencies
 76 |         run: |
 77 |           uv python install 3.12
 78 |           uv sync --all-extras --dev
 79 |           uv add --dev bandit
 80 |   
 81 |       - name: ⚙️ Run security scan with bandit
 82 |         run: |
 83 |           uv run bandit -r src/
 84 |   
 85 |     test:
 86 |       runs-on: ubuntu-latest
 87 |       needs: validate-release
 88 |       strategy:
 89 |         matrix:
 90 |           python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
 91 |   
 92 |       services:
 93 |         redis:
 94 |           image: redis:latest
 95 |           ports:
 96 |             - 6379:6379
 97 |           options: >-
 98 |             --health-cmd "redis-cli ping"
 99 |             --health-interval 10s
100 |             --health-timeout 5s
101 |             --health-retries 5
102 |   
103 |       steps:
104 |       - name: ⚙️ Harden Runner
105 |         uses: step-security/harden-runner@v2
106 |         with:
107 |           egress-policy: audit
108 |   
109 |       - name: ⚙️ Checkout the project
110 |         uses: actions/checkout@v5
111 |         with:
112 |           fetch-depth: 0
113 |   
114 |       - name: ⚙️ Install uv
115 |         uses: astral-sh/setup-uv@v7
116 |         with:
117 |           version: "latest"
118 |           enable-cache: false
119 |   
120 |       - name: ⚙️ Set up Python ${{ matrix.python-version }}
121 |         run: |
122 |           uv python install ${{ matrix.python-version }}
123 |           uv sync --all-extras --dev
124 |   
125 |       - name: ⚙️ Run tests
126 |         run: uv run pytest tests/ -v --tb=short
127 |         env:
128 |           REDIS_HOST: localhost
129 |           REDIS_PORT: 6379
130 |   
131 |       - name: ⚙️ Test MCP server startup
132 |         run: |
133 |           timeout 10s uv run python src/main.py || test $? = 124
134 |         env:
135 |           REDIS_HOST: localhost
136 |           REDIS_PORT: 6379
137 |   
138 |     build-and-publish:
139 |       runs-on: ubuntu-latest
140 |       needs: [validate-release, security-scan, test]
141 |       environment:
142 |         name: ${{ github.event.inputs.environment || 'pypi' }}
143 |         url: ${{ github.event.inputs.environment == 'testpypi' && 'https://test.pypi.org/p/redis-mcp-server' || 'https://pypi.org/p/redis-mcp-server' }}
144 |       permissions:
145 |         id-token: write  # IMPORTANT: mandatory for trusted publishing
146 |         contents: read
147 |         attestations: write  # For build attestations
148 |   
149 |       steps:
150 |       - name: ⚙️ Harden Runner
151 |         uses: step-security/harden-runner@v2
152 |         with:
153 |           egress-policy: audit
154 |   
155 |       - name: ⚙️ Checkout the project
156 |         uses: actions/checkout@v5
157 |         with:
158 |           fetch-depth: 0  # Full history for UV build
159 |   
160 |       - name: ⚙️ Install uv
161 |         uses: astral-sh/setup-uv@v7
162 |         with:
163 |           version: "latest"
164 |           enable-cache: false
165 |   
166 |       - name: ⚙️ Set up Python
167 |         run: uv python install 3.12
168 |   
169 |       - name: ⚙️ Override version in pyproject.toml
170 |         if: (github.event_name == 'workflow_dispatch' && github.event.inputs.version) || github.event_name == 'release'
171 |         run: |
172 |           # Determine the target version
173 |           if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
174 |             TARGET_VERSION="${{ github.event.inputs.version }}"
175 |             echo "Overriding version from manual trigger: $TARGET_VERSION"
176 |           elif [[ "${{ github.event_name }}" == "release" ]]; then
177 |             RELEASE_TAG="${{ github.event.release.tag_name }}"
178 |             TARGET_VERSION=$(echo "$RELEASE_TAG" | sed 's/^v//')
179 |             echo "Overriding version from release tag: $TARGET_VERSION (tag: $RELEASE_TAG)"
180 |           fi
181 |             
182 |           # Get current version for comparison
183 |           CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
184 |           echo "Current version in pyproject.toml: $CURRENT_VERSION"
185 |             
186 |           # Check if override is needed
187 |           if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
188 |             echo "Version already matches target: $TARGET_VERSION"
189 |           else
190 |             echo "Version override needed: $CURRENT_VERSION → $TARGET_VERSION"
191 |             
192 |             # Update version in pyproject.toml
193 |             sed -i "s/^version = \".*\"/version = \"$TARGET_VERSION\"/" pyproject.toml
194 |             
195 |             # Verify the change
196 |             NEW_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
197 |             echo "Updated version in pyproject.toml: $NEW_VERSION"
198 |             
199 |             # Validate the change was successful
200 |             if [[ "$NEW_VERSION" != "$TARGET_VERSION" ]]; then
201 |               echo "Version override failed! Expected: $TARGET_VERSION, Got: $NEW_VERSION"
202 |               exit 1
203 |             fi
204 |             
205 |             echo "Version successfully changed: $CURRENT_VERSION → $NEW_VERSION"
206 |           fi
207 |   
208 |       - name: ⚙️ Build package
209 |         run: |
210 |           uv build --sdist --wheel
211 |   
212 |       - name: ⚙️ Check package
213 |         run: |
214 |           uv add --dev twine
215 |           uv run twine check dist/*
216 |   
217 |       - name: ⚙️ Generate build attestation
218 |         uses: actions/attest-build-provenance@v3
219 |         with:
220 |           subject-path: 'dist/*'
221 |   
222 |       - name: ⚙️ Publish to PyPI
223 |         if: ${{ !inputs.dry_run }}
224 |         uses: pypa/gh-action-pypi-publish@release/v1
225 |         with:
226 |           repository-url: ${{ github.event.inputs.environment == 'testpypi' && 'https://test.pypi.org/legacy/' || '' }}
227 |           print-hash: true
228 |           attestations: true
229 |   
230 |       - name: ⚙️ Dry run - Package ready for publishing
231 |         if: ${{ inputs.dry_run }}
232 |         run: |
233 |           echo "🔍 DRY RUN MODE - Package built successfully but not published"
234 |           echo "📦 Built packages:"
235 |           ls -la dist/
236 |           echo ""
237 |           echo "✅ Package is ready for publishing to ${{ github.event.inputs.environment || 'pypi' }}"
238 |   
239 |       - name: ⚙️ Upload build artifacts
240 |         uses: actions/upload-artifact@v5
241 |         with:
242 |           name: dist-${{ needs.validate-release.outputs.version }}
243 |           path: dist/
244 |           retention-days: 90
245 |   
246 |     notify-success:
247 |       runs-on: ubuntu-latest
248 |       needs: [validate-release, build-and-publish]
249 |       if: success()
250 |       steps:
251 |       - name: ⚙️ Success notification
252 |         run: |
253 |           if [[ "${{ inputs.dry_run }}" == "true" ]]; then
254 |             echo "🔍 DRY RUN COMPLETED - Redis MCP Server v${{ github.event.inputs.version || needs.validate-release.outputs.version }} ready for release!"
255 |             echo "📦 Package built successfully but not published"
256 |             echo "🎯 Target environment: ${{ github.event.inputs.environment || 'pypi' }}"
257 |           else
258 |             echo "🎉 Successfully released Redis MCP Server v${{ github.event.inputs.version || needs.validate-release.outputs.version }} to ${{ github.event.inputs.environment || 'PyPI' }}!"
259 |             if [[ "${{ github.event.inputs.environment }}" == "testpypi" ]]; then
260 |               echo "📦 Package: https://test.pypi.org/project/redis-mcp-server/${{ github.event.inputs.version || needs.validate-release.outputs.version }}/"
261 |             else
262 |               echo "📦 Package: https://pypi.org/project/redis-mcp-server/${{ github.event.inputs.version || needs.validate-release.outputs.version }}/"
263 |             fi
264 |             if [[ "${{ github.event_name }}" == "release" ]]; then
265 |               echo "🏷️ Release: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
266 |             else
267 |               echo "🚀 Manual release triggered by: ${{ github.actor }}"
268 |             fi
269 |           fi
270 | 
```
Page 1/2FirstPrevNextLast