diff --git a/README.md b/README.md index bb959ff..fd6b6b6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The Redis MCP Server is a **natural language interface** designed for agentic ap - [Redis ACL](#redis-acl) - [Configuration via command line arguments](#configuration-via-command-line-arguments) - [Configuration via Environment Variables](#configuration-via-environment-variables) + - [Logging](#logging) - [Integrations](#integrations) - [OpenAI Agents SDK](#openai-agents-sdk) - [Augment](#augment) @@ -78,7 +79,7 @@ Additional tools. ## Installation -The Redis MCP Server is available as a PyPI package and as direct installation from the GitHub repository. +The Redis MCP Server is available as a PyPI package and as direct installation from the GitHub repository. ### From PyPI (recommended) 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. @@ -125,7 +126,7 @@ However, starting the MCP Server is most useful when delegate to the framework o 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). -> It is recommended to use a tagged release, the `main` branch is under active development and may contain breaking changes. +> It is recommended to use a tagged release, the `main` branch is under active development and may contain breaking changes. As an example, you can execute the following command to run the `0.2.0` release: @@ -140,7 +141,7 @@ Additional examples are provided below. # Run with Redis URI uvx --from git+https://github.com/redis/mcp-redis.git redis-mcp-server --url redis://localhost:6379/0 -# Run with Redis URI and SSL +# Run with Redis URI and SSL uvx --from git+https://github.com/redis/mcp-redis.git redis-mcp-server --url "rediss://:@:?ssl_cert_reqs=required&ssl_ca_certs=" # Run with individual parameters @@ -318,7 +319,7 @@ If desired, you can use environment variables. Defaults are provided for all var There are several ways to set environment variables: -1. **Using a `.env` File**: +1. **Using a `.env` File**: 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`). For example, create a `.env` file with the following content from the `.env.example` file provided in the repository: @@ -330,7 +331,7 @@ Then edit the `.env` file to set your Redis configuration: OR, -2. **Setting Variables in the Shell**: +2. **Setting Variables in the Shell**: You can export environment variables directly in your shell before running your application. For example: ```sh @@ -342,6 +343,46 @@ export REDIS_PORT=6379 This method is useful for temporary overrides or quick testing. +### Logging + +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. + +- Accepted values (case-insensitive): `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, `NOTSET` +- Aliases supported: `WARN` → `WARNING`, `FATAL` → `CRITICAL` +- Numeric values are also accepted, including signed (e.g., `"10"`, `"+20"`) +- Default when unset or unrecognized: `WARNING` + +Handler behavior +- 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. +- If no handlers are present, the server adds a single stderr StreamHandler with a simple format. + +Examples +```bash +# See normal lifecycle messages +MCP_REDIS_LOG_LEVEL=INFO uv run src/main.py + +# Very verbose for debugging +MCP_REDIS_LOG_LEVEL=DEBUG uvx --from redis-mcp-server@latest redis-mcp-server --url redis://localhost:6379/0 +``` + +In MCP client configs that support env, add it alongside your Redis settings. For example: +```json +{ + "mcpServers": { + "redis": { + "command": "uvx", + "args": ["--from", "redis-mcp-server@latest", "redis-mcp-server", "--url", "redis://localhost:6379/0"], + "env": { + "REDIS_HOST": "localhost", + "REDIS_PORT": "6379", + "MCP_REDIS_LOG_LEVEL": "INFO" + } + } + } +} +``` + + ## Integrations 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. diff --git a/examples/redis_assistant.py b/examples/redis_assistant.py index 56668ec..aa4095e 100644 --- a/examples/redis_assistant.py +++ b/examples/redis_assistant.py @@ -12,15 +12,17 @@ async def build_agent(): params={ "command": "uv", "args": [ - "--directory", "../src/", # change with the path to the MCP server - "run", "main.py" + "--directory", + "../src/", # change with the path to the MCP server + "run", + "main.py", ], - "env": { - "REDIS_HOST": "127.0.0.1", - "REDIS_PORT": "6379", - "REDIS_USERNAME": "default", - "REDIS_PWD": "" - }, + "env": { + "REDIS_HOST": "127.0.0.1", + "REDIS_PORT": "6379", + "REDIS_USERNAME": "default", + "REDIS_PWD": "", + }, } ) @@ -30,7 +32,7 @@ async def build_agent(): agent = Agent( name="Redis Assistant", instructions="You are a helpful assistant capable of reading and writing to Redis. Store every question and answer in the Redis Stream app:logger", - mcp_servers=[server] + mcp_servers=[server], ) return agent @@ -46,7 +48,7 @@ async def cli(agent, max_history=30): if q.strip().lower() in {"exit", "quit"}: break - if (len(q.strip()) > 0): + if len(q.strip()) > 0: # Format the context into a single string history = "" for turn in conversation_history: @@ -58,7 +60,9 @@ async def cli(agent, max_history=30): response_text = "" async for event in result.stream_events(): - if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent): + if event.type == "raw_response_event" and isinstance( + event.data, ResponseTextDeltaEvent + ): print(event.data.delta, end="", flush=True) response_text += event.data.delta print("\n") diff --git a/src/common/connection.py b/src/common/connection.py index 52f29e6..7c5554e 100644 --- a/src/common/connection.py +++ b/src/common/connection.py @@ -1,4 +1,4 @@ -import sys +import logging from typing import Optional, Type, Union import redis @@ -8,6 +8,8 @@ from src.common.config import REDIS_CFG from src.version import __version__ +_logger = logging.getLogger(__name__) + class RedisConnectionManager: _instance: Optional[Redis] = None @@ -57,25 +59,25 @@ def get_connection(cls, decode_responses=True) -> Redis: cls._instance = redis_class(**connection_params) except redis.exceptions.ConnectionError: - print("Failed to connect to Redis server", file=sys.stderr) + _logger.error("Failed to connect to Redis server") raise except redis.exceptions.AuthenticationError: - print("Authentication failed", file=sys.stderr) + _logger.error("Authentication failed") raise except redis.exceptions.TimeoutError: - print("Connection timed out", file=sys.stderr) + _logger.error("Connection timed out") raise except redis.exceptions.ResponseError as e: - print(f"Response error: {e}", file=sys.stderr) + _logger.error("Response error: %s", e) raise except redis.exceptions.RedisError as e: - print(f"Redis error: {e}", file=sys.stderr) + _logger.error("Redis error: %s", e) raise except redis.exceptions.ClusterError as e: - print(f"Redis Cluster error: {e}", file=sys.stderr) + _logger.error("Redis Cluster error: %s", e) raise except Exception as e: - print(f"Unexpected error: {e}", file=sys.stderr) + _logger.error("Unexpected error: %s", e) raise return cls._instance diff --git a/src/common/logging_utils.py b/src/common/logging_utils.py new file mode 100644 index 0000000..37e337a --- /dev/null +++ b/src/common/logging_utils.py @@ -0,0 +1,65 @@ +import logging +import os +import sys + + +def resolve_log_level() -> int: + """Resolve desired log level from MCP_REDIS_LOG_LEVEL. + + Accepts numeric strings or standard level names (DEBUG, INFO, WARNING, + ERROR, CRITICAL, NOTSET) including aliases WARN and FATAL. Defaults to WARNING. + """ + name = os.getenv("MCP_REDIS_LOG_LEVEL") + if name: + s = name.strip() + try: + return int(s) + except ValueError: + pass + level = getattr(logging, s.upper(), None) + if isinstance(level, int): + return level + return logging.WARNING + + +def configure_logging() -> int: + """Configure logging based on environment. + + - Default level WARNING + - MCP_REDIS_LOG_LEVEL to override + + Returns the resolved log level. Idempotent. + """ + + level = resolve_log_level() + root = logging.getLogger() + + # Always set the root logger level + root.setLevel(level) + + # Only lower overly-restrictive handler thresholds to avoid host filtering. + # - Leave NOTSET (0) alone so it defers to logger/root levels + # - Do not raise handler thresholds (respect host-configured verbosity) + for h in root.handlers: + try: + cur = getattr(h, "level", None) + if isinstance(cur, int) and cur != logging.NOTSET and cur > level: + h.setLevel(level) + except Exception: + # Log at DEBUG to avoid noisy stderr while still providing diagnostics. + logging.getLogger(__name__).debug( + "Failed to adjust handler level for handler %r", h, exc_info=True + ) + + # Only add our own stderr handler if there are NO handlers at all. + # Many hosts (pytest, uv, VS Code) install a console handler already. + if not root.handlers: + sh = logging.StreamHandler(sys.stderr) + sh.setLevel(level) + sh.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) + root.addHandler(sh) + + # Route warnings.warn(...) through logging + logging.captureWarnings(True) + + return level diff --git a/src/main.py b/src/main.py index f1796d1..c673c5e 100644 --- a/src/main.py +++ b/src/main.py @@ -1,14 +1,19 @@ import sys +import logging import click from src.common.config import parse_redis_uri, set_redis_config_from_cli from src.common.server import mcp +from src.common.logging_utils import configure_logging class RedisMCPServer: def __init__(self): - print("Starting the Redis MCP Server", file=sys.stderr) + # Configure logging on server initialization (idempotent) + configure_logging() + self._logger = logging.getLogger(__name__) + self._logger.info("Starting the Redis MCP Server") def run(self): mcp.run() diff --git a/tests/test_logging_utils.py b/tests/test_logging_utils.py new file mode 100644 index 0000000..4cefd02 --- /dev/null +++ b/tests/test_logging_utils.py @@ -0,0 +1,145 @@ +import logging +import sys +import pytest + +from src.common.logging_utils import resolve_log_level, configure_logging + + +@pytest.fixture() +def preserve_logging(): + """Snapshot and restore the root logger state to avoid cross-test interference.""" + root = logging.getLogger() + saved_level = root.level + saved_handlers = list(root.handlers) + saved_handler_levels = [h.level for h in saved_handlers] + try: + yield + finally: + # Remove any handlers added during the test + for h in list(root.handlers): + try: + root.removeHandler(h) + except Exception: + pass + # Restore original handlers and their levels + for h, lvl in zip(saved_handlers, saved_handler_levels): + try: + root.addHandler(h) + h.setLevel(lvl) + except Exception: + pass + # Restore original root level + root.setLevel(saved_level) + # Best-effort: disable warnings capture enabled by configure_logging + try: + logging.captureWarnings(False) + except Exception: + pass + + +def test_resolve_log_level_default_warning(monkeypatch): + monkeypatch.delenv("MCP_REDIS_LOG_LEVEL", raising=False) + assert resolve_log_level() == logging.WARNING + + +def test_resolve_log_level_parses_name_and_alias(monkeypatch): + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "info") + assert resolve_log_level() == logging.INFO + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "WARN") + assert resolve_log_level() == logging.WARNING + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "fatal") + assert resolve_log_level() == logging.CRITICAL + + +def test_resolve_log_level_parses_numeric(monkeypatch): + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "10") + assert resolve_log_level() == 10 + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "+20") + assert resolve_log_level() == 20 + + +def test_configure_logging_adds_stderr_handler_when_none(monkeypatch, preserve_logging): + # Ensure no handlers exist before configuring + root = logging.getLogger() + for h in list(root.handlers): + root.removeHandler(h) + + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "INFO") + level = configure_logging() + + assert level == logging.INFO + assert len(root.handlers) == 1, ( + "Should add exactly one stderr handler when none exist" + ) + handler = root.handlers[0] + assert isinstance(handler, logging.StreamHandler) + # StreamHandler exposes the underlying stream attribute + assert getattr(handler, "stream", None) is sys.stderr + assert handler.level == logging.INFO + assert root.level == logging.INFO + + +def test_configure_logging_only_lowers_restrictive_handlers( + monkeypatch, preserve_logging +): + root = logging.getLogger() + # Start from a clean handler set + for h in list(root.handlers): + root.removeHandler(h) + + # Add two handlers: one restrictive WARNING, one permissive NOTSET + h_warning = logging.StreamHandler(sys.stderr) + h_warning.setLevel(logging.WARNING) + root.addHandler(h_warning) + + h_notset = logging.StreamHandler(sys.stderr) + h_notset.setLevel(logging.NOTSET) + root.addHandler(h_notset) + + # Request DEBUG + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "DEBUG") + configure_logging() + + # The WARNING handler should be lowered to DEBUG; NOTSET should remain NOTSET + assert h_warning.level == logging.DEBUG + assert h_notset.level == logging.NOTSET + + +def test_configure_logging_does_not_raise_handler_threshold( + monkeypatch, preserve_logging +): + root = logging.getLogger() + # Clean handlers + for h in list(root.handlers): + root.removeHandler(h) + + # Add a handler at WARNING and then set env to ERROR + h_warning = logging.StreamHandler(sys.stderr) + h_warning.setLevel(logging.WARNING) + root.addHandler(h_warning) + + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "ERROR") + configure_logging() + + # Handler should remain at WARNING (30), not be raised to ERROR (40) + assert h_warning.level == logging.WARNING + # Root level should reflect ERROR + assert root.level == logging.ERROR + + +def test_configure_logging_does_not_add_handler_if_exists( + monkeypatch, preserve_logging +): + root = logging.getLogger() + # Start with one existing handler + for h in list(root.handlers): + root.removeHandler(h) + existing = logging.StreamHandler(sys.stderr) + root.addHandler(existing) + + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "INFO") + configure_logging() + + # Should not add another handler + assert len(root.handlers) == 1 + assert root.handlers[0] is existing diff --git a/tests/test_main.py b/tests/test_main.py index 15ed1a7..84f9409 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,8 @@ Unit tests for src/main.py """ +import logging + from unittest.mock import Mock, patch import pytest @@ -13,13 +15,21 @@ class TestRedisMCPServer: """Test cases for RedisMCPServer class.""" - def test_init_prints_startup_message(self, capsys): - """Test that RedisMCPServer initialization prints startup message.""" - server = RedisMCPServer() - assert server is not None + def test_init_logs_startup_message(self, capsys, caplog, monkeypatch): + """Startup should emit an INFO log; client may route it via handlers. + Accept either stderr output or log record text. + """ + monkeypatch.setenv("MCP_REDIS_LOG_LEVEL", "INFO") + + with caplog.at_level(logging.INFO): + server = RedisMCPServer() + assert server is not None captured = capsys.readouterr() - assert "Starting the Redis MCP Server" in captured.err + stderr_text = captured.err or "" + log_text = caplog.text or "" # collected by pytest logging handler + combined = stderr_text + "\n" + log_text + assert "Starting the Redis MCP Server" in combined @patch("src.main.mcp.run") def test_run_calls_mcp_run(self, mock_mcp_run):