diff --git a/README.md b/README.md index 1e55674..5e1b7cb 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,35 @@ uv run agent-memory mcp uv run agent-memory mcp --mode sse --port 9000 --no-worker ``` +### MCP config via uvx (recommended) + +Use this in your MCP tool configuration (e.g., Claude Desktop mcp.json): + +```json +{ + "mcpServers": { + "memory": { + "command": "uvx", + "args": ["--from", "agent-memory-server", "agent-memory", "mcp"], + "env": { + "DISABLE_AUTH": "true", + "REDIS_URL": "redis://localhost:6379", + "OPENAI_API_KEY": "" + } + } + } +} +``` + +Notes: +- API keys: Set either `OPENAI_API_KEY` (default models use OpenAI) or switch to Anthropic by setting `ANTHROPIC_API_KEY` and `GENERATION_MODEL` to an Anthropic model (e.g., `claude-3-5-haiku-20241022`). + +- Make sure your MCP host can find `uvx` (on its PATH or by using an absolute command path). + - macOS: `brew install uv` + - If not on PATH, set `"command"` to the absolute path (e.g., `/opt/homebrew/bin/uvx` on Apple Silicon, `/usr/local/bin/uvx` on Intel macOS). On Linux, `~/.local/bin/uvx` is common. See https://docs.astral.sh/uv/getting-started/ +- For production, remove `DISABLE_AUTH` and configure proper authentication. + + ## Documentation 📚 **[Full Documentation](https://redis.github.io/agent-memory-server/)** - Complete guides, API reference, and examples diff --git a/agent_memory_server/__init__.py b/agent_memory_server/__init__.py index 2d39331..e45e0b0 100644 --- a/agent_memory_server/__init__.py +++ b/agent_memory_server/__init__.py @@ -1,3 +1,3 @@ """Redis Agent Memory Server - A memory system for conversational AI.""" -__version__ = "0.12.4" +__version__ = "0.12.5" diff --git a/agent_memory_server/config.py b/agent_memory_server/config.py index 3d57cd4..a1760a3 100644 --- a/agent_memory_server/config.py +++ b/agent_memory_server/config.py @@ -235,6 +235,7 @@ class Settings(BaseSettings): # Cloud ## Cloud region region_name: str | None = None + ## AWS Cloud credentials aws_access_key_id: str | None = None aws_secret_access_key: str | None = None diff --git a/agent_memory_server/extraction.py b/agent_memory_server/extraction.py index 6780a03..06b7929 100644 --- a/agent_memory_server/extraction.py +++ b/agent_memory_server/extraction.py @@ -5,8 +5,8 @@ import ulid from tenacity.asyncio import AsyncRetrying from tenacity.stop import stop_after_attempt -from transformers import AutoModelForTokenClassification, AutoTokenizer, pipeline +# Lazy-import transformers in get_ner_model to avoid heavy deps at startup from agent_memory_server.config import settings from agent_memory_server.filters import DiscreteMemoryExtracted, MemoryType from agent_memory_server.llms import ( @@ -61,9 +61,27 @@ def get_ner_model() -> Any: """ global _ner_model, _ner_tokenizer if _ner_model is None: + # Lazy import to avoid importing heavy ML frameworks at process startup + try: + from transformers import ( + AutoModelForTokenClassification, + AutoTokenizer, + pipeline as hf_pipeline, + ) + except Exception as e: + logger.warning( + "Transformers not available or failed to import; NER disabled: %s", e + ) + raise + _ner_tokenizer = AutoTokenizer.from_pretrained(settings.ner_model) _ner_model = AutoModelForTokenClassification.from_pretrained(settings.ner_model) - return pipeline("ner", model=_ner_model, tokenizer=_ner_tokenizer) + return hf_pipeline("ner", model=_ner_model, tokenizer=_ner_tokenizer) + + # If already initialized, import the lightweight symbol and return a new pipeline + from transformers import pipeline as hf_pipeline # type: ignore + + return hf_pipeline("ner", model=_ner_model, tokenizer=_ner_tokenizer) def extract_entities(text: str) -> list[str]: diff --git a/agent_memory_server/long_term_memory.py b/agent_memory_server/long_term_memory.py index 994a5fb..e9dbb95 100644 --- a/agent_memory_server/long_term_memory.py +++ b/agent_memory_server/long_term_memory.py @@ -893,6 +893,25 @@ async def search_long_term_memories( Returns: MemoryRecordResults containing matching memories """ + # If no query text is provided, perform a filter-only listing (no semantic search). + # This enables patterns like: "return all memories for this user/namespace". + if not (text or "").strip(): + adapter = await get_vectorstore_adapter() + return await adapter.list_memories( + session_id=session_id, + user_id=user_id, + namespace=namespace, + created_at=created_at, + last_accessed=last_accessed, + topics=topics, + entities=entities, + memory_type=memory_type, + event_date=event_date, + memory_hash=memory_hash, + limit=limit, + offset=offset, + ) + # Optimize query for vector search if requested. search_query = text optimized_applied = False diff --git a/agent_memory_server/mcp.py b/agent_memory_server/mcp.py index 3b5bfdd..0e19931 100644 --- a/agent_memory_server/mcp.py +++ b/agent_memory_server/mcp.py @@ -521,8 +521,12 @@ async def search_long_term_memory( limit=limit, offset=offset, ) + # Create a background tasks instance for the MCP call + from agent_memory_server.dependencies import HybridBackgroundTasks + + background_tasks = HybridBackgroundTasks() results = await core_search_long_term_memory( - payload, optimize_query=optimize_query + payload, background_tasks=background_tasks, optimize_query=optimize_query ) return MemoryRecordResults( total=results.total, diff --git a/agent_memory_server/vectorstore_adapter.py b/agent_memory_server/vectorstore_adapter.py index 94c8069..bc992dd 100644 --- a/agent_memory_server/vectorstore_adapter.py +++ b/agent_memory_server/vectorstore_adapter.py @@ -403,6 +403,12 @@ def parse_datetime(dt_val: str | float | None) -> datetime | None: # Unix timestamp from Redis return datetime.fromtimestamp(dt_val, tz=UTC) if isinstance(dt_val, str): + # Try to parse as float first (Unix timestamp as string) + try: + timestamp = float(dt_val) + return datetime.fromtimestamp(timestamp, tz=UTC) + except ValueError: + pass # ISO string from other backends return datetime.fromisoformat(dt_val) return None diff --git a/docker-compose.yml b/docker-compose.yml index e01014f..fbae1d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,12 @@ services: image: redislabs/agent-memory-server:${REDIS_AGENT_MEMORY_VERSION:-latest} ports: - "8000:8000" + env_file: + - path: .env + required: false environment: - REDIS_URL=redis://redis:6379 - PORT=8000 - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GENERATION_MODEL=gpt-4o-mini - EMBEDDING_MODEL=text-embedding-3-small - LONG_TERM_MEMORY=True @@ -44,11 +45,12 @@ services: mcp: profiles: ["standard", ""] image: redislabs/agent-memory-server:${REDIS_AGENT_MEMORY_VERSION:-latest} + env_file: + - path: .env + required: false environment: - REDIS_URL=redis://redis:6379 - PORT=9050 - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GENERATION_MODEL=gpt-4o-mini - EMBEDDING_MODEL=text-embedding-3-small - LONG_TERM_MEMORY=True @@ -66,10 +68,11 @@ services: task-worker: profiles: ["standard", ""] image: redislabs/agent-memory-server:${REDIS_AGENT_MEMORY_VERSION:-latest} + env_file: + - path: .env + required: false environment: - REDIS_URL=redis://redis:6379 - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GENERATION_MODEL=gpt-4o-mini - EMBEDDING_MODEL=text-embedding-3-small - LONG_TERM_MEMORY=True @@ -94,16 +97,12 @@ services: image: redislabs/agent-memory-server-aws:${REDIS_AGENT_MEMORY_AWS_VERSION:-latest} ports: - "8000:8000" + env_file: + - path: .env + required: false environment: - REDIS_URL=redis://redis:6379 - PORT=8000 - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - # AWS Bedrock configuration - - REGION_NAME=${REGION_NAME} - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} - GENERATION_MODEL=anthropic.claude-haiku-4-5-20251001-v1:0 - EMBEDDING_MODEL=amazon.titan-embed-text-v2:0 - LONG_TERM_MEMORY=True @@ -125,16 +124,12 @@ services: mcp-aws: profiles: ["aws"] image: redislabs/agent-memory-server-aws:${REDIS_AGENT_MEMORY_AWS_VERSION:-latest} + env_file: + - path: .env + required: false environment: - REDIS_URL=redis://redis:6379 - PORT=9050 - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - # AWS Bedrock configuration - - REGION_NAME=${REGION_NAME} - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} - GENERATION_MODEL=anthropic.claude-haiku-4-5-20251001-v1:0 - EMBEDDING_MODEL=amazon.titan-embed-text-v2:0 - LONG_TERM_MEMORY=True @@ -152,15 +147,11 @@ services: task-worker-aws: profiles: ["aws"] image: redislabs/agent-memory-server-aws:${REDIS_AGENT_MEMORY_AWS_VERSION:-latest} + env_file: + - path: .env + required: false environment: - REDIS_URL=redis://redis:6379 - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - # AWS Bedrock configuration - - REGION_NAME=${REGION_NAME} - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} - GENERATION_MODEL=anthropic.claude-haiku-4-5-20251001-v1:0 - EMBEDDING_MODEL=amazon.titan-embed-text-v2:0 - LONG_TERM_MEMORY=True diff --git a/docs/getting-started.md b/docs/getting-started.md index 9211789..80d43a4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,6 +48,32 @@ uv run agent-memory mcp --mode sse --no-worker uv run agent-memory mcp --mode sse ``` +### Using uvx in MCP clients + +When configuring MCP-enabled apps (e.g., Claude Desktop), prefer `uvx` so the app can run the server without a local checkout: + +```json +{ + "mcpServers": { + "memory": { + "command": "uvx", + "args": ["--from", "agent-memory-server", "agent-memory", "mcp"], + "env": { + "DISABLE_AUTH": "true", + "REDIS_URL": "redis://localhost:6379", + "OPENAI_API_KEY": "" + } + } + } +} +``` + +Notes: +- API keys: Default models use OpenAI. Set `OPENAI_API_KEY`. To use Anthropic instead, set `ANTHROPIC_API_KEY` and also `GENERATION_MODEL` to an Anthropic model (e.g., `claude-3-5-haiku-20241022`). +- Make sure your MCP host can find `uvx` (on its PATH or by using an absolute command path). macOS: `brew install uv`. If not on PATH, set `"command"` to an absolute path (e.g., `/opt/homebrew/bin/uvx` on Apple Silicon, `/usr/local/bin/uvx` on Intel macOS). +- For production, remove `DISABLE_AUTH` and configure auth. + + **For production deployments**, you'll need to run a separate worker process: ```bash diff --git a/docs/mcp.md b/docs/mcp.md index 570f7b0..d7af5c1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -67,29 +67,68 @@ You can use the MCP server that comes with this project in any application or SD -For example, with Claude, use the following configuration: +For Claude, the easiest way is to use uvx (recommended): ```json { "mcpServers": { - "redis-memory-server": { + "memory": { + "command": "uvx", + "args": ["--from", "agent-memory-server", "agent-memory", "mcp"], + "env": { + "DISABLE_AUTH": "true", + "REDIS_URL": "redis://localhost:6379", + "OPENAI_API_KEY": "" + } + } + } +} +``` + +Notes: +- API keys: Default models use OpenAI. Set `OPENAI_API_KEY`. To use Anthropic instead, set `ANTHROPIC_API_KEY` and also `GENERATION_MODEL` to an Anthropic model (e.g., `claude-3-5-haiku-20241022`). +- Make sure your MCP host can find `uvx` (on its PATH or by using an absolute command path). + - macOS: `brew install uv` + - If not on PATH, set `"command"` to an absolute path (e.g., `/opt/homebrew/bin/uvx` on Apple Silicon, `/usr/local/bin/uvx` on Intel macOS). On Linux, `~/.local/bin/uvx` is common. See https://docs.astral.sh/uv/getting-started/ for distro specifics +- Set `DISABLE_AUTH=false` in production and configure proper auth per the Authentication guide. + +If you’re running from a local checkout instead of PyPI, you can use `uv run` with a directory: + +```json +{ + "mcpServers": { + "memory": { "command": "uv", "args": [ "--directory", "/ABSOLUTE/PATH/TO/REPO/DIRECTORY/agent-memory-server", "run", "agent-memory", - "mcp", - "--mode", - "stdio" + "mcp" ] } } } ``` -**NOTE:** On a Mac, this configuration requires that you use `brew install uv` to install uv. Probably any method that makes the `uv` -command globally accessible, so Claude can find it, would work. +Alternative (Anthropic): + +```json +{ + "mcpServers": { + "memory": { + "command": "uvx", + "args": ["--from", "agent-memory-server", "agent-memory", "mcp"], + "env": { + "DISABLE_AUTH": "true", + "REDIS_URL": "redis://localhost:6379", + "ANTHROPIC_API_KEY": "", + "GENERATION_MODEL": "claude-3-5-haiku-20241022" + } + } + } +} +``` ### Cursor diff --git a/tests/test_long_term_memory.py b/tests/test_long_term_memory.py index 19942a6..03ce7ba 100644 --- a/tests/test_long_term_memory.py +++ b/tests/test_long_term_memory.py @@ -4,7 +4,7 @@ import pytest -from agent_memory_server.filters import SessionId +from agent_memory_server.filters import Namespace, SessionId from agent_memory_server.long_term_memory import ( compact_long_term_memories, count_long_term_memories, @@ -883,6 +883,96 @@ async def test_deduplicate_by_id_with_user_id_real_redis_error( # Re-raise to see the full traceback raise + @pytest.mark.asyncio + async def test_search_with_empty_query_returns_all_memories( + self, async_redis_client + ): + """Test that an empty query returns all memories (filter-only search).""" + # Create distinct memories + long_term_memories = [ + MemoryRecord( + id="empty-query-test-1", + text="The Eiffel Tower is in Paris", + session_id="empty-query-session", + namespace="empty-query-ns", + ), + MemoryRecord( + id="empty-query-test-2", + text="Mount Everest is the tallest mountain", + session_id="empty-query-session", + namespace="empty-query-ns", + ), + MemoryRecord( + id="empty-query-test-3", + text="The Pacific Ocean is the largest ocean", + session_id="empty-query-session", + namespace="empty-query-ns", + ), + ] + + # Index memories + await index_long_term_memories( + long_term_memories, + redis_client=async_redis_client, + ) + + # Search with empty query - should return all memories for this session/namespace + results = await search_long_term_memories( + text="", + session_id=SessionId(eq="empty-query-session"), + namespace=Namespace(eq="empty-query-ns"), + limit=10, + ) + + # Verify we get all 3 memories back + assert results.total == 3, f"Expected 3 results, got {results.total}" + assert len(results.memories) == 3 + + # Verify all our memories are present (order may vary) + result_ids = {m.id for m in results.memories} + expected_ids = { + "empty-query-test-1", + "empty-query-test-2", + "empty-query-test-3", + } + assert result_ids == expected_ids, f"Expected {expected_ids}, got {result_ids}" + + @pytest.mark.asyncio + async def test_search_with_whitespace_query_returns_all_memories( + self, async_redis_client + ): + """Test that a whitespace-only query returns all memories (filter-only search).""" + long_term_memories = [ + MemoryRecord( + id="whitespace-query-test-1", + text="Apple makes iPhones", + session_id="whitespace-query-session", + namespace="whitespace-query-ns", + ), + MemoryRecord( + id="whitespace-query-test-2", + text="Google makes Android", + session_id="whitespace-query-session", + namespace="whitespace-query-ns", + ), + ] + + await index_long_term_memories( + long_term_memories, + redis_client=async_redis_client, + ) + + # Search with whitespace-only query + results = await search_long_term_memories( + text=" ", + session_id=SessionId(eq="whitespace-query-session"), + namespace=Namespace(eq="whitespace-query-ns"), + limit=10, + ) + + assert results.total == 2, f"Expected 2 results, got {results.total}" + assert len(results.memories) == 2 + @pytest.mark.asyncio class TestSearchQueryOptimization: @@ -986,10 +1076,9 @@ async def test_search_with_empty_query_skips_optimization( # Verify optimization was NOT called for empty query mock_optimize.assert_not_called() - # Verify adapter was called with empty query - mock_adapter.search_memories.assert_called_once() - call_kwargs = mock_adapter.search_memories.call_args[1] - assert call_kwargs["query"] == "" + # Verify adapter performed filter-only listing (no semantic search) + mock_adapter.list_memories.assert_called_once() + mock_adapter.search_memories.assert_not_called() @patch("agent_memory_server.long_term_memory.get_vectorstore_adapter") @patch("agent_memory_server.long_term_memory.optimize_query_for_vector_search") diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 11d1de9..3ccd792 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -180,7 +180,9 @@ async def test_default_namespace_injection(self, monkeypatch): # Capture injected namespace injected = {} - async def fake_core_search(payload, optimize_query=False): + async def fake_core_search( + payload, background_tasks=None, optimize_query=False + ): injected["namespace"] = payload.namespace.eq if payload.namespace else None # Return a dummy result with total>0 to skip fake fallback return MemoryRecordResults( @@ -590,3 +592,39 @@ async def test_memory_prompt_with_optimize_query_true_explicit( call_args = mock_prompt.call_args optimize_query = call_args[1]["optimize_query"] assert optimize_query is True + + @pytest.mark.asyncio + async def test_search_long_term_memory_passes_background_tasks( + self, session, mcp_test_setup + ): + """Regression test: MCP search_long_term_memory must pass background_tasks to core API. + + This test ensures that the MCP tool correctly passes a HybridBackgroundTasks + instance to the core_search_long_term_memory function, which requires it. + """ + from agent_memory_server.dependencies import HybridBackgroundTasks + + async with client_session(mcp_app._mcp_server) as client: + with mock.patch( + "agent_memory_server.mcp.core_search_long_term_memory" + ) as mock_search: + mock_search.return_value = MemoryRecordResults(total=0, memories=[]) + + # Call search_long_term_memory via MCP + await client.call_tool( + "search_long_term_memory", + {"text": "test query"}, + ) + + # Verify search was called with background_tasks parameter + mock_search.assert_called_once() + call_args = mock_search.call_args + + # background_tasks should be passed as a keyword argument + assert ( + "background_tasks" in call_args[1] + ), "background_tasks parameter must be passed to core_search_long_term_memory" + background_tasks = call_args[1]["background_tasks"] + assert isinstance( + background_tasks, HybridBackgroundTasks + ), f"background_tasks should be HybridBackgroundTasks, got {type(background_tasks)}"