Compare commits

..
Author SHA1 Message Date
kartik-mem0 406110460d refactor: add legacy_path override to legacy data migration helper 2026-04-02 19:23:38 +05:30
kartik-mem0 29b9059cea refactor: add legacy /tmp data migration to new vector store path 2026-04-02 18:59:09 +05:30
utkarsh240799andClaude Opus 4.6 a84a565422 fix: resolve lint errors in test_default_path.py
Remove unused imports (os, tempfile), unused variables, and
extraneous f-string prefixes flagged by ruff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:47:35 +05:30
utkarsh240799andClaude Opus 4.6 0b397eaf06 fix: use persistent platform-appropriate dirs instead of /tmp for vector store defaults (#4279)
Replace hardcoded /tmp/{provider} default paths with XDG-compliant persistent
directories (~/.local/share/mem0/{provider}), fixing failures on Windows,
macOS LaunchAgents, Linux systemd, and Docker. Adds MEM0_DATA_DIR env var
override, legacy /tmp data migration warning, and updates Qdrant/ChromaDB/FAISS
configs and docs to use the new defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:31:58 +05:30
Zamuldinov Nikitaandutkarsh240799 051551edb7 fix: use platform-appropriate user data dir instead of /tmp for vector store paths
The default vector store path was hardcoded to /tmp/{provider}, which fails in:
- macOS LaunchAgents: /tmp cleared on reboot, limited permissions
- Linux systemd: may have noexec restrictions or private /tmp namespaces
- Windows: /tmp does not exist
- Docker: /tmp is ephemeral unless mounted as a volume

Replace with a cross-platform user data directory:
  - Linux/macOS: $XDG_DATA_HOME or ~/.local/share/mem0/{provider}
  - Windows:     %APPDATA%/mem0/{provider}

This follows the XDG Base Directory Specification on Linux and the
conventional APPDATA path on Windows, making mem0 work reliably in
all deployment environments without requiring explicit path config.

Closes #4279
2026-03-26 18:28:36 +05:30
7e3b727528 Fix: prevent double embedding in mem0.add (fixes #3723) (#3996)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:14:48 +05:30
13c7f84eec MCP: add Streamable HTTP transport endpoint (#4122)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:36:37 +05:30
924ac00c52 feat: expose infer param in MCP add_memories tool (#4517)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:53:50 +05:30
2a36960f4c fix: prevent in-place mutation of metadata in _create_memory (#4529)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:56:30 +05:30
HimanshuandGitHub 2e0f91e70d fix(bedrock): omit topP for Anthropic Converse; use AWSBedrockConfig in LlmFactory (#4469) 2026-03-25 11:22:52 +05:30
Saket AryanandGitHub d1b4b304c7 chore: replace local MCP and Smithery with cloud MCP server (#4532) 2026-03-25 04:58:00 +05:30
Saket AryanandGitHub 2868bfe749 docs: remove OpenMemory references from docs, README, and issue templates (#4520) 2026-03-25 04:24:13 +05:30
KartikandGitHub 5431badfd4 fix: preserve custom metadata when updating memory (#4495) 2026-03-24 10:58:21 +05:30
39 changed files with 2077 additions and 833 deletions
-1
View File
@@ -14,7 +14,6 @@ body:
- Vector Store (Qdrant, PGVector, Redis, Chroma, etc.)
- Graph Memory (Neo4j, Memgraph, etc.)
- Ollama / Local Models
- OpenMemory (MCP Server)
- OpenClaw
- REST API
- Other
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Discord Community
url: https://discord.gg/6PzXDgEjG5
@@ -14,7 +14,6 @@ body:
- Vector Store (Qdrant, PGVector, Redis, Chroma, etc.)
- Graph Memory (Neo4j, Memgraph, etc.)
- Ollama / Local Models
- OpenMemory (MCP Server)
- OpenClaw
- REST API
- Benchmarks / Evals
-2
View File
@@ -12,8 +12,6 @@ component:
matcher: "Graph Memory"
- label: "ollama"
matcher: "Ollama"
- label: "openmemory"
matcher: "OpenMemory"
- label: "openclaw"
matcher: "OpenClaw"
- label: "rest-api"
-2
View File
@@ -15,8 +15,6 @@
<a href="https://mem0.dev/DiG">Join Discord</a>
·
<a href="https://mem0.dev/demo">Demo</a>
·
<a href="https://mem0.dev/openmemory">OpenMemory</a>
</p>
<p align="center">
+1 -1
View File
@@ -52,7 +52,7 @@ Here are the parameters available for configuring FAISS:
| Parameter | Description | Default Value |
| --- | --- | --- |
| `collection_name` | The name of the collection | `mem0` |
| `path` | Path to store FAISS index and metadata | `/tmp/faiss/<collection_name>` |
| `path` | Path to store FAISS index and metadata | `~/.local/share/mem0/faiss/<collection_name>` |
| `distance_strategy` | Distance metric strategy to use (options: 'euclidean', 'inner_product', 'cosine') | `euclidean` |
| `normalize_L2` | Whether to normalize L2 vectors (only applicable for euclidean distance) | `False` |
+2 -2
View File
@@ -73,7 +73,7 @@ Let's see the available parameters for the `qdrant` config:
| `client` | Custom client for qdrant | `None` |
| `host` | The host where the qdrant server is running | `None` |
| `port` | The port where the qdrant server is running | `None` |
| `path` | Path for the qdrant database | `/tmp/qdrant` |
| `path` | Path for the qdrant database | `~/.local/share/mem0/qdrant` |
| `url` | Full URL for the qdrant server | `None` |
| `api_key` | API key for the qdrant server | `None` |
| `on_disk` | For enabling persistent storage | `False` |
@@ -85,7 +85,7 @@ Let's see the available parameters for the `qdrant` config:
| `embeddingModelDims` | Dimensions of the embedding model | `1536` |
| `host` | The host where the Qdrant server is running | `None` |
| `port` | The port where the Qdrant server is running | `None` |
| `path` | Path for the Qdrant database | `/tmp/qdrant` |
| `path` | Path for the Qdrant database | `~/.local/share/mem0/qdrant` |
| `url` | Full URL for the Qdrant server | `None` |
| `apiKey` | API key for the Qdrant server | `None` |
| `onDisk` | For enabling persistent storage | `False` |
@@ -3,7 +3,7 @@ title: "Gemini 3 with Mem0 MCP"
description: "Create snappy, smart, memory-aware agents by pairing Gemini 3 with Mem0 MCP server."
---
Gemini 3, when paired with mem0-mcp-server, works in synergy to create snappy, smart, memory-aware agents.
Gemini 3, when paired with Mem0's cloud MCP server, works in synergy to create snappy, smart, memory-aware agents.
<Callout type="info" icon="sparkles" color="#8B5CF6">
This is the primary example of MCP integration - the same patterns work with Claude Desktop, Cursor, or any MCP-compatible client.
@@ -27,10 +27,22 @@ The Mem0 MCP server provides these tools to Gemini:
## Setup
### Configure Mem0 MCP
Add Mem0 MCP to your MCP client:
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
```
### Install dependencies
```bash
pip install pydantic-ai nest-asyncio python-dotenv uv google-genai
pip install pydantic-ai nest-asyncio python-dotenv google-genai
```
### Environment Setup
@@ -40,7 +52,6 @@ Create a file named `.env`:
```bash
MEM0_API_KEY=m0-xxxxxxxxxxxxxxxxx
GEMINI_API_KEY=your-gemini-api-key-here
MEM0_DEFAULT_USER_ID=demo-user
```
<Note>
@@ -60,7 +71,7 @@ import asyncio
import os
from dotenv import load_dotenv
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStdio
from pydantic_ai.mcp import MCPServerHTTP
# Load environment variables
load_dotenv()
@@ -75,11 +86,9 @@ class MemoryAgent:
def _setup(self):
"""Initialize the agent with MCP tools"""
# Create MCP server directly
self.server = MCPServerStdio(
command="uvx",
args=["mem0-mcp-server"],
env=os.environ
# Connect to Mem0's cloud MCP server
self.server = MCPServerHTTP(
url="https://mcp.mem0.ai/mcp"
)
# Create agent with Gemini and memory tools
+12 -15
View File
@@ -296,21 +296,6 @@
}
]
},
{
"tab": "OpenMemory",
"groups": [
{
"group": "Overview & Quickstart",
"icon": "square-terminal",
"pages": [
"openmemory/overview",
"vibecoding",
"openmemory/quickstart",
"openmemory/integrations"
]
}
]
},
{
"tab": "Cookbooks",
"groups": [
@@ -1056,6 +1041,18 @@
{
"source": "/cookbooks/customer-support-agent",
"destination": "/cookbooks/operations/support-inbox"
},
{
"source": "/openmemory/overview",
"destination": "/introduction"
},
{
"source": "/openmemory/quickstart",
"destination": "/introduction"
},
{
"source": "/openmemory/integrations",
"destination": "/introduction"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

+1 -26
View File
@@ -31,7 +31,7 @@ mode: "custom"
</h2>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 sm:grid-cols-2">
<a
href="/platform/overview"
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
@@ -84,31 +84,6 @@ mode: "custom"
</div>
</a>
<a
href="/openmemory/overview"
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
>
<img
className="block dark:hidden aspect-[4/3] w-full object-cover"
src="/images/docs thumbnails/light/mem0_openmemory.png"
alt="OpenMemory thumbnail"
style={{pointerEvents: "none"}}
/>
<img
className="hidden dark:block aspect-[4/3] w-full object-cover"
src="/images/docs thumbnails/dark/mem0_openmemory.png"
alt="OpenMemory thumbnail"
style={{pointerEvents: "none"}}
/>
<div className="flex flex-1 flex-col gap-3 px-5 pb-6 pt-5 text-left">
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
OpenMemory
</h3>
<p className="text-sm text-gray-600 dark:text-zinc-400">
Workspace-based memory for teams collaborating across agents and projects.
</p>
</div>
</a>
</div>
</section>
-1
View File
@@ -213,4 +213,3 @@ Key differentiators:
- [FAQs](https://docs.mem0.ai/platform/faqs): Frequently asked questions about Mem0's Platform capabilities and implementation details
- [Changelog](https://docs.mem0.ai/changelog): Detailed product updates and version history for tracking new features and improvements
- [Contributing Guide](https://docs.mem0.ai/contributing/development): Guidelines for contributing to Mem0's open-source development
- [OpenMemory](https://docs.mem0.ai/openmemory/overview): Open-source memory infrastructure for research and experimentation
+1 -1
View File
@@ -77,7 +77,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
Mem0 OSS works out of the box with sensible defaults:
- LLM: OpenAI `gpt-4.1-nano-2025-04-14` (via `OPENAI_API_KEY`)
- Embeddings: OpenAI `text-embedding-3-small`
- Vector store: Local Qdrant instance storing data at `/tmp/qdrant`
- Vector store: Local Qdrant instance storing data at `~/.local/share/mem0/qdrant`
- History store: SQLite database at `~/.mem0/history.db`
- Reranker: Disabled until you configure a provider
+1 -1
View File
@@ -81,7 +81,7 @@ print(results)
By default `Memory()` wires up:
- OpenAI `gpt-4.1-nano-2025-04-14` for fact extraction and updates
- OpenAI `text-embedding-3-small` embeddings (1536 dimensions)
- Qdrant vector store with on-disk data at `/tmp/qdrant`
- Qdrant vector store with on-disk data at `~/.local/share/mem0/qdrant`
- SQLite history at `~/.mem0/history.db`
- No reranker (add one in the config when you need it)
</Note>
-56
View File
@@ -1,56 +0,0 @@
---
title: MCP Client Integration Guide
description: "Connect MCP-compatible clients to a locally running OpenMemory server for seamless memory integration."
icon: "plug"
iconType: "solid"
---
## Connecting an MCP Client
Once your OpenMemory server is running locally, you can connect any compatible MCP client to your personal memory stream. This enables a seamless memory layer integration for AI tools and agents.
Ensure the following environment variables are correctly set in your configuration files:
**In `/ui/.env`:**
```env
NEXT_PUBLIC_API_URL=http://localhost:8765
NEXT_PUBLIC_USER_ID=<user-id>
```
**In `/api/.env`:**
```env
OPENAI_API_KEY=sk-xxx
USER=<user-id>
```
These values define where your MCP server is running and which user's memory is accessed.
### MCP Client Setup
Use the following one-step command to configure OpenMemory Local MCP to a client. The general command format is as follows:
```bash
npx @openmemory/install local http://localhost:8765/mcp/<client-name>/sse/<user-id> --client <client-name>
```
Replace `<client-name>` with the desired client name and `<user-id>` with the value specified in your environment variables.
### Example Commands for Supported Clients
| Client | Command |
|-------------|---------|
| Claude | `npx install-mcp http://localhost:8765/mcp/claude/sse/<user-id> --client claude` |
| Cursor | `npx install-mcp http://localhost:8765/mcp/cursor/sse/<user-id> --client cursor` |
| Cline | `npx install-mcp http://localhost:8765/mcp/cline/sse/<user-id> --client cline` |
| RooCline | `npx install-mcp http://localhost:8765/mcp/roocline/sse/<user-id> --client roocline` |
| Windsurf | `npx install-mcp http://localhost:8765/mcp/windsurf/sse/<user-id> --client windsurf` |
| Witsy | `npx install-mcp http://localhost:8765/mcp/witsy/sse/<user-id> --client witsy` |
| Enconvo | `npx install-mcp http://localhost:8765/mcp/enconvo/sse/<user-id> --client enconvo` |
| Augment | `npx install-mcp http://localhost:8765/mcp/augment/sse/<user-id> --client augment` |
| Claude Code | `claude mcp add -s user -t sse OpenMemory http://localhost:8765/mcp/openmemory/sse/<user-id>` |
### What This Does
Running one of the above commands registers the specified MCP client and connects it to your OpenMemory server. This enables the client to stream and store contextual memory for the provided user ID.
The connection status and memory activity can be monitored via the OpenMemory UI at [http://localhost:3000](http://localhost:3000).
-124
View File
@@ -1,124 +0,0 @@
---
title: Overview
description: "Overview of OpenMemory, a local and hosted memory infrastructure powered by Mem0 with MCP server support."
icon: "info"
iconType: "solid"
---
## Hosted OpenMemory MCP Now Available
#### Sign Up Now - [app.openmemory.dev](https://app.openmemory.dev)
Everything you love about OpenMemory MCP but with zero setup.
- Works with all MCP-compatible tools (Claude Desktop, Cursor, etc.)
- Same standard memory operations: `add_memories`, `search_memory`, etc.
- One-click provisioning, no Docker required
- Powered by Mem0
Add shared, persistent, low-friction memory to your MCP-compatible clients in seconds.
### Get Started Now
Sign up and get your access key at [app.openmemory.dev](https://app.openmemory.dev).
Example installation: `npx @openmemory/install --client claude --env OPENMEMORY_API_KEY=your-key`
OpenMemory is a local memory infrastructure powered by Mem0 that lets you carry your memory across any AI app. It provides a unified memory layer that stays with you, enabling agents and assistants to remember what matters across applications.
<img src="https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b" alt="OpenMemory UI" />
## What is the OpenMemory MCP Server
The OpenMemory MCP Server is a private, local-first memory server that creates a shared, persistent memory layer for your MCP-compatible tools. It runs entirely on your machine, enabling seamless context handoff across tools. Whether you're switching between development, planning, or debugging environments, your AI assistants can access relevant memory without needing repeated instructions.
The OpenMemory MCP Server ensures all memory stays local, structured, and under your control with no cloud sync or external storage.
## OpenMemory Easy Setup
### Prerequisites
- Docker
- OpenAI API Key
You can quickly run OpenMemory by running the following command:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
```
You should set the `OPENAI_API_KEY` as a global environment variable:
```bash
export OPENAI_API_KEY=your_api_key
```
You can also set the `OPENAI_API_KEY` as a parameter to the script:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
```
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store. We suggest you follow the instructions [here](/openmemory/quickstart#setting-up-openmemory) to set up OpenMemory on your local machine with a more persistent memory store.
## How the OpenMemory MCP Server Works
Built around the Model Context Protocol (MCP), the OpenMemory MCP Server exposes a standardized set of memory tools:
- `add_memories`: Store new memory objects
- `search_memory`: Retrieve relevant memories
- `list_memories`: View all stored memory
- `delete_all_memories`: Clear memory entirely
Any MCP-compatible tool can connect to the server and use these APIs to persist and access memory.
## What It Enables
### Cross-Client Memory Access
Store context in Cursor and retrieve it later in Claude or Windsurf without repeating yourself.
### Fully Local Memory Store
All memory is stored on your machine. Nothing goes to the cloud. You maintain full ownership and control.
### Unified Memory UI
The built-in OpenMemory dashboard provides a central view of everything stored. Add, browse, delete, and control memory access to clients directly from the dashboard.
## Supported Clients
The OpenMemory MCP Server is compatible with any client that supports the Model Context Protocol. This includes:
- Cursor
- Claude Desktop
- Windsurf
- Cline
- And more
As more AI systems adopt MCP, your private memory becomes more valuable.
## Real-World Examples
### Scenario 1: Cross-Tool Project Flow
Define technical requirements of a project in Claude Desktop. Build in Cursor. Debug issues in Windsurf - all with shared context passed through OpenMemory.
### Scenario 2: Preferences That Persist
Set your preferred code style or tone in one tool. When you switch to another MCP client, it can access those same preferences without redefining them.
### Scenario 3: Project Knowledge
Save important project details once, then access them from any compatible AI tool - no more repetitive explanations.
## Conclusion
The OpenMemory MCP Server brings memory to MCP-compatible tools without giving up control or privacy. It solves a foundational limitation in modern LLM workflows: the loss of context across tools, sessions, and environments.
By standardizing memory operations and keeping all data local, it reduces token overhead, improves performance, and unlocks more intelligent interactions across the growing ecosystem of AI assistants.
This is just the beginning. The MCP server is the first core layer in the OpenMemory platform, a broader effort to make memory portable, private, and interoperable across AI systems.
## Getting Started Today
- Repository: [GitHub](https://github.com/mem0ai/mem0/tree/main/openmemory)
- Join our community: [Discord](https://discord.gg/6PzXDgEjG5)
With OpenMemory, your AI memories stay private, portable, and under your control, exactly where they belong.
OpenMemory: Your memories, your control.
## Contributing
OpenMemory is open source and we welcome contributions. Please see the [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/openmemory/CONTRIBUTING.md) file for more information.
-197
View File
@@ -1,197 +0,0 @@
---
title: Quickstart
description: "Get started with hosted or self-hosted OpenMemory MCP, including API key setup and client installation."
icon: "terminal"
iconType: "solid"
---
## Hosted OpenMemory MCP Now Available
#### Sign Up Now - [app.openmemory.dev](https://app.openmemory.dev)
Everything you love about OpenMemory MCP but with zero setup.
- Works with all MCP-compatible tools (Claude Desktop, Cursor, etc.)
- Same standard memory operations: `add_memories`, `search_memory`, etc.
- One-click provisioning, no Docker required
- Powered by Mem0
Add shared, persistent, low-friction memory to your MCP-compatible clients in seconds.
### Get Started Now
Sign up and get your access key at [app.openmemory.dev](https://app.openmemory.dev).
Example installation: `npx @openmemory/install --client claude --env OPENMEMORY_API_KEY=your-key`
## Getting Started with Hosted OpenMemory
The fastest way to get started is with our hosted version - no setup required.
### 1. Get Your API Key
Visit [app.openmemory.dev](https://app.openmemory.dev) to sign up and get your `OPENMEMORY_API_KEY`.
### 2. Install and Connect to Your Preferred Client
Example commands (replace `your-key` with your actual API key):
**For Claude Desktop:**
```bash
npx @openmemory/install --client claude --env OPENMEMORY_API_KEY=your-key
```
**For Cursor:**
```bash
npx @openmemory/install --client cursor --env OPENMEMORY_API_KEY=your-key
```
**For Windsurf:**
```bash
npx @openmemory/install --client windsurf --env OPENMEMORY_API_KEY=your-key
```
That's it! Your AI client now has persistent memory across sessions.
## Local Setup (Self-Hosted)
Prefer to run OpenMemory locally? Follow the instructions below for a self-hosted setup.
## OpenMemory Easy Setup
### Prerequisites
- Docker
- OpenAI API Key
You can quickly run OpenMemory by running the following command:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
```
You should set the `OPENAI_API_KEY` as a global environment variable:
```bash
export OPENAI_API_KEY=your_api_key
```
You can also set the `OPENAI_API_KEY` as a parameter to the script:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
```
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store. We suggest you follow the instructions below to set up OpenMemory on your local machine with a more persistent memory store.
## Setting Up OpenMemory
Getting started with OpenMemory is straightforward and takes just a few minutes to set up on your local machine. Follow these steps:
### 1. Clone the Repository
```bash
# Clone the repository
git clone https://github.com/mem0ai/mem0.git
cd mem0/openmemory
```
### 2. Set Up Environment Variables
Before running the project, you need to configure environment variables for both the API and the UI.
You can do this in one of the following ways:
- **Manually:** Create a `.env` file in each of the following directories:
- `/api/.env`
- `/ui/.env`
- **Using `.env.example` files:** Copy and rename the example files:
```bash
cp api/.env.example api/.env
cp ui/.env.example ui/.env
```
- **Using Makefile** (if supported): Run:
```bash
make env
```
#### Example `/api/.env`
```bash
OPENAI_API_KEY=sk-xxx
USER=<user-id> # The User ID you want to associate the memories with
```
#### LLM Configuration (optional)
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider by adding these variables to `/api/.env`:
| Variable | Description | Default |
|---|---|---|
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
**Example: Using Ollama (fully local)**
```bash
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1:latest
EMBEDDER_PROVIDER=ollama
EMBEDDER_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://localhost:11434
```
**Example: Using Anthropic**
```bash
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
LLM_API_KEY=sk-ant-xxx
```
#### Example `/ui/.env`
```bash
NEXT_PUBLIC_API_URL=http://localhost:8765
NEXT_PUBLIC_USER_ID=<user-id> # Same as the user ID for environment variable in api
```
### 3. Build and Run the Project
You can run the project using the following two commands:
```bash
make build # Builds the MCP server and UI
make up # Runs OpenMemory MCP server and UI
```
After running these commands, you will have:
- OpenMemory MCP server running at http://localhost:8765 (API documentation available at http://localhost:8765/docs)
- OpenMemory UI running at http://localhost:3000
#### UI Not Working on http://localhost:3000?
If the UI does not start properly on http://localhost:3000, try running it manually:
```bash
cd ui
pnpm install
pnpm dev
```
You can configure the MCP client using the following command (replace `username` with your username):
```bash
npx @openmemory/install local "http://localhost:8765/mcp/cursor/sse/username" --client cursor
```
**For Claude Code (CLI):**
```bash
claude mcp add -s user -t sse OpenMemory "http://localhost:8765/mcp/openmemory/sse/username"
```
The OpenMemory dashboard will be available at http://localhost:3000. From here, you can view and manage your memories and check connection status with your MCP clients.
Once set up, OpenMemory runs locally on your machine, ensuring all your AI memories remain private and secure while being accessible across any compatible MCP client.
## Getting Started Today
GitHub Repository: https://github.com/mem0ai/mem0/tree/main/openmemory
+46 -139
View File
@@ -9,11 +9,48 @@ description: "Connect any AI client to Mem0 using Model Context Protocol for uni
When building AI applications, memory management often requires manual integration. MCP eliminates this complexity by:
- **Universal compatibility**: Works with any MCP-compatible client (Claude Desktop, Cursor, custom agents)
- **Universal compatibility**: Works with any MCP-compatible client (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode)
- **Agent autonomy**: AI agents decide when to save, search, or update memories
- **Zero infrastructure**: No servers to maintain - Mem0 handles everything
- **Zero infrastructure**: No servers to maintain - Mem0's cloud MCP handles everything
- **Standardized protocol**: One integration works across all your AI tools
## Setup
Add Mem0 MCP to all supported clients with a single command:
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
```
Or configure a specific client:
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "cursor"
```
For manual configuration, add this to your MCP client config:
```json
{
"mcpServers": {
"mem0-mcp": {
"type": "http",
"url": "https://mcp.mem0.ai/mcp"
}
}
}
```
For detailed per-client instructions, see the [Mem0 MCP Quickstart](/platform/mem0-mcp).
## Available tools
The MCP server exposes 9 memory tools to your AI client:
@@ -30,139 +67,12 @@ The MCP server exposes 9 memory tools to your AI client:
| `get_memory` | Retrieve single memory by ID |
| `list_entities` | View stored entities |
## Deployment options
## How it works
Choose the deployment method that fits your workflow:
<AccordionGroup>
<Accordion title="Python package (recommended)">
Install and run locally with uvx:
```bash
uv pip install mem0-mcp-server
```
Configure your client:
```json
{
"mcpServers": {
"mem0": {
"command": "uvx",
"args": ["mem0-mcp-server"],
"env": {
"MEM0_API_KEY": "m0-...",
"MEM0_DEFAULT_USER_ID": "your-handle"
}
}
}
}
```
</Accordion>
<Accordion title="Docker container">
Containerized deployment with HTTP endpoint:
```bash
docker build -t mem0-mcp-server https://github.com/mem0ai/mem0-mcp.git
docker run --rm -d -e MEM0_API_KEY="m0-..." -p 8080:8081 mem0-mcp-server
```
Configure for HTTP:
```json
{
"mcpServers": {
"mem0-docker": {
"command": "curl",
"args": ["-X", "POST", "http://localhost:8080/mcp", "--data-binary", "@"],
"env": {
"MEM0_API_KEY": "m0-..."
}
}
}
}
```
</Accordion>
<Accordion title="Smithery">
One-click setup with managed service:
Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@mem0ai/mem0-memory-mcp) and:
1. Select your AI client (Cursor, Claude Desktop, etc.)
2. Configure your Mem0 API key
3. Set your default user ID
4. Enable graph memory (optional)
5. Copy the generated configuration
Your client connects automatically - no installation required.
</Accordion>
</AccordionGroup>
## Configuration
### Required environment variables
```bash
MEM0_API_KEY="m0-..." # Your Mem0 API key
MEM0_DEFAULT_USER_ID="your-handle" # Default user ID
```
### Optional variables
```bash
MEM0_ENABLE_GRAPH_DEFAULT="true" # Enable graph memories
MEM0_MCP_AGENT_MODEL="gpt-4o-mini" # LLM for bundled examples
```
<AccordionGroup>
<Accordion title="Test your setup with the Python agent">
The included Pydantic AI agent provides an interactive REPL to test memory operations:
```bash
# Install the package
pip install mem0-mcp-server
# Set your API keys
export MEM0_API_KEY="m0-..."
export OPENAI_API_KEY="sk-openai-..."
# Clone and test with the agent
git clone https://github.com/mem0ai/mem0-mcp.git
cd mem0-mcp-server
python example/pydantic_ai_repl.py
```
**Testing different server configurations:**
- **Local server** (default): `python example/pydantic_ai_repl.py`
- **Docker container**:
```bash
export MEM0_MCP_CONFIG_PATH=example/docker-config.json
export MEM0_MCP_CONFIG_SERVER=mem0-docker
python example/pydantic_ai_repl.py
```
- **Smithery remote**:
```bash
export MEM0_MCP_CONFIG_PATH=example/config-smithery.json
export MEM0_MCP_CONFIG_SERVER=mem0-memory-mcp
python example/pydantic_ai_repl.py
```
Try these test prompts:
- "Remember that I love tiramisu"
- "Search for my food preferences"
- "Update my project: the mobile app is now 80% complete"
- "Show me all memories about project Phoenix"
- "Delete memories from 2023"
</Accordion>
</AccordionGroup>
## How the testing works
1. **Configuration loads** - Reads from `example/config.json` by default
2. **Server starts** - Launches or connects to the Mem0 MCP server
3. **Agent connects** - Pydantic AI agent (Mem0Guide) attaches to the server
4. **Interactive REPL** - You get a chat interface to test all memory operations
1. **Configure the MCP server** - Add Mem0 MCP to your AI client using the setup command above
2. **Agent connects** - Your AI client connects to Mem0's cloud MCP server over HTTP
3. **Autonomous memory** - The agent decides when to store/retrieve memories as part of its reasoning
4. **No manual API calls** - The agent manages memory automatically through MCP tools
## Example interactions
@@ -225,14 +135,11 @@ The Mem0 MCP server enables powerful memory capabilities for your AI application
## Best practices
- **Start simple**: Use the Python package for development
- **Use the cloud MCP**: The hosted MCP server at `https://mcp.mem0.ai/mcp` handles infrastructure for you
- **Use wildcards**: `user_id: "*"` to search across all users
- **Test locally**: Use the bundled Python agent to verify setup
- **Monitor usage**: Track memory operations in the dashboard
- **Document patterns**: Share successful prompt patterns with your team
{/* DEBUG: verify CTA targets */}
<CardGroup cols={2}>
<Card
title="Memory Filters"
@@ -246,4 +153,4 @@ The Mem0 MCP server enables powerful memory capabilities for your AI application
icon="book-open"
href="/cookbooks/frameworks/gemini-3-with-mem0-mcp"
/>
</CardGroup>
</CardGroup>
+108 -141
View File
@@ -2,28 +2,34 @@
title: "Mem0 MCP"
description: "Connect any AI client to Mem0 using Model Context Protocol in minutes"
icon: "puzzle-piece"
estimatedTime: "~5 minutes"
estimatedTime: "~2 minutes"
---
<Info>
**Prerequisites**
- Mem0 Platform account ([Sign up here](https://app.mem0.ai))
- API key ([Get one from dashboard](https://app.mem0.ai/settings/api-keys))
- Python 3.10+, Docker, or Node.js 14+
- An MCP-compatible client (Claude Desktop, Cursor, or custom agent)
- Node.js 14+ (for npx)
- An MCP-compatible client (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode)
</Info>
## What is Mem0 MCP?
Mem0 MCP Server exposes Mem0's memory capabilities as MCP tools, letting AI agents decide when to save, search, or update information.
Mem0 MCP Server exposes Mem0's memory capabilities as MCP tools, letting AI agents decide when to save, search, or update information. The cloud-hosted MCP server requires no local installation — just connect and start using memory.
## Deployment Options
## Quick Setup
Choose from three deployment methods:
Add Mem0 MCP to your preferred clients with a single command:
1. **Python Package (Recommended)** - Install locally with `uvx` for instant setup
2. **Docker Container** - Isolated deployment with HTTP endpoint
3. **Smithery** - Remote hosted service for managed deployments
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
```
This automatically configures Mem0 MCP for all supported clients at once.
## Available Tools
@@ -43,54 +49,105 @@ The MCP server exposes these memory tools to your AI client:
---
## Quickstart with Python (UVX)
## Client-Specific Setup
<Steps>
<Step title="Install the MCP Server">
```bash
uv pip install mem0-mcp-server
```
</Step>
You can also configure individual clients:
<Step title="Configure your MCP client">
Add this to your MCP client (e.g., Claude Desktop):
<AccordionGroup>
<Accordion title="Claude Desktop">
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude"
```
```json
{
"mcpServers": {
"mem0": {
"command": "uvx",
"args": ["mem0-mcp-server"],
"env": {
"MEM0_API_KEY": "m0-...",
"MEM0_DEFAULT_USER_ID": "your-handle"
Or manually add to your Claude Desktop configuration (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"mem0-mcp": {
"type": "http",
"url": "https://mcp.mem0.ai/mcp"
}
}
}
}
}
```
```
</Accordion>
Set your environment variables:
<Accordion title="Claude Code">
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude code"
```
</Accordion>
```bash
export MEM0_API_KEY="m0-..."
export MEM0_DEFAULT_USER_ID="your-handle"
```
</Step>
<Accordion title="Cursor">
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "cursor"
```
<Step title="Test with the Python agent">
```bash
# Clone the mem0-mcp repository
git clone https://github.com/mem0ai/mem0-mcp.git
cd mem0-mcp
Or go to Cursor → Settings → MCP and add:
```json
{
"mcpServers": {
"mem0-mcp": {
"type": "http",
"url": "https://mcp.mem0.ai/mcp"
}
}
}
```
</Accordion>
# Set your API keys
export MEM0_API_KEY="m0-..."
export OPENAI_API_KEY="sk-openai-..."
<Accordion title="Windsurf">
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "windsurf"
```
</Accordion>
# Run the interactive agent
python example/pydantic_ai_repl.py
```
<Accordion title="VS Code">
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "vscode"
```
</Accordion>
<Accordion title="OpenCode">
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "opencode"
```
</Accordion>
</AccordionGroup>
---
## Verify Your Setup
Once configured, your AI client can:
- Automatically save information with `add_memory`
- Search memories with `search_memories`
- Update memories with `update_memory`
- Delete memories with `delete_memory`
**Sample Interactions:**
@@ -104,107 +161,18 @@ Agent: Based on your memories, you love tiramisu.
User: Update my project: the mobile app is now 80% complete
Agent: Updated your project status successfully.
```
</Step>
<Step title="Verify the setup">
Your AI client can now:
- Automatically save information with `add_memory`
- Search memories with `search_memories`
- Update memories with `update_memory`
- Delete memories with `delete_memory`
<Info icon="check">
If you get "Connection failed", ensure your API key is valid and the server is running.
If you get "Connection failed", ensure you have a valid API key from [Mem0 Dashboard](https://app.mem0.ai/settings/api-keys).
</Info>
</Step>
</Steps>
---
## Quickstart with Docker
<Steps>
<Step title="Build the Docker image">
```bash
docker build -t mem0-mcp-server https://github.com/mem0ai/mem0-mcp.git
```
</Step>
<Step title="Run the container">
```bash
docker run --rm -d \
--name mem0-mcp \
-e MEM0_API_KEY="m0-..." \
-p 8080:8081 \
mem0-mcp-server
```
</Step>
<Step title="Configure your client for HTTP">
For clients that connect via HTTP (instead of stdio):
```json
{
"mcpServers": {
"mem0-docker": {
"command": "curl",
"args": ["-X", "POST", "http://localhost:8080/mcp", "--data-binary", "@-"],
"env": {
"MEM0_API_KEY": "m0-..."
}
}
}
}
```
</Step>
<Step title="Verify the setup">
```bash
# Check container logs
docker logs mem0-mcp
# Test HTTP endpoint
curl http://localhost:8080/health
```
<Info icon="check">
The container should start successfully and respond to HTTP requests. If port 8080 is occupied, change it with `-p 8081:8081`.
</Info>
</Step>
</Steps>
---
## Quickstart with Smithery (Hosted)
For the simplest integration, use Smithery's hosted Mem0 MCP server - no installation required.
**Example: One-click setup in Cursor**
1. Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@mem0ai/mem0-memory-mcp) and select Cursor as your client
![Smithery Mem0 MCP Configuration](/images/smithery-mem0-mcp.png)
2. Open Cursor → Settings → MCP
3. Click `mem0-mcp` → Initiate authorization
4. Configure Smithery with your environment:
- `MEM0_API_KEY`: Your Mem0 API key
- `MEM0_DEFAULT_USER_ID`: Your user ID
- `MEM0_ENABLE_GRAPH_DEFAULT`: Optional, set to `true` for graph memories
5. Return to Cursor settings and wait for tools to load
6. Start chatting with Cursor and begin storing preferences
**For other clients:**
Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@mem0ai/mem0-memory-mcp) to connect any MCP-compatible client with your Mem0 credentials.
---
## Quick Recovery
- **"uvx command not found"** → Install with `pip install uv` or use `pip install mem0-mcp-server` instead. Make sure your Python environment has `uv` installed (or system-wide).
- **"Connection refused"** → Check that the server is running and the correct port is configured
- **"Connection refused"** → Check your internet connection and ensure the MCP client is correctly configured
- **"Invalid API key"** → Get a new key from [Mem0 Dashboard](https://app.mem0.ai/settings/api-keys)
- **"Permission denied"** → Ensure Docker has access to bind ports (try with `sudo` on Linux)
- **"npx command not found"** → Install Node.js from [nodejs.org](https://nodejs.org)
---
@@ -227,6 +195,5 @@ Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@m
## Additional Resources
- **[Mem0 MCP Repository](https://github.com/mem0ai/mem0-mcp)** - Source code and examples
- **[Platform Quickstart](/platform/quickstart)** - Direct API integration guide
- **[MCP Specification](https://modelcontextprotocol.io)** - Learn about MCP protocol
- **[MCP Specification](https://modelcontextprotocol.io)** - Learn about MCP protocol
+16
View File
@@ -30,6 +30,22 @@ npx skills add https://github.com/mem0ai/mem0 --skill mem0
Works with Claude Code, Cursor, Windsurf, and any assistant that supports skills. Once installed, your assistant understands Mem0's full API, framework integrations, and common patterns.
## MCP Server Setup
Connect Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode, or any MCP-compatible client to Mem0.
Get your API key from [app.mem0.ai](https://app.mem0.ai), then add Mem0 MCP with a single command:
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
```
For per-client setup and advanced options, see [Mem0 MCP Setup](/platform/mem0-mcp).
## Universal Starter Prompt
Copy this into any AI tool to start building with Mem0:
+8 -4
View File
@@ -16,7 +16,7 @@ class AWSBedrockConfig(BaseLlmConfig):
model: Optional[str] = None,
temperature: float = 0.1,
max_tokens: int = 2000,
top_p: float = 0.9,
top_p: Optional[float] = None,
top_k: int = 1,
aws_access_key_id: Optional[str] = None,
aws_secret_access_key: Optional[str] = None,
@@ -33,7 +33,8 @@ class AWSBedrockConfig(BaseLlmConfig):
model: Bedrock model identifier (e.g., "amazon.nova-3-mini-20241119-v1:0")
temperature: Controls randomness (0.0 to 2.0)
max_tokens: Maximum tokens to generate
top_p: Nucleus sampling parameter (0.0 to 1.0)
top_p: Nucleus sampling (0.0–1.0). Default None omits topP on Converse
(required for Anthropic, which rejects temperature and topP together).
top_k: Top-k sampling parameter (1 to 40)
aws_access_key_id: AWS access key (optional, uses env vars if not provided)
aws_secret_access_key: AWS secret key (optional, uses env vars if not provided)
@@ -75,13 +76,16 @@ class AWSBedrockConfig(BaseLlmConfig):
def get_model_config(self) -> Dict[str, Any]:
"""Get model-specific configuration parameters."""
base_config = {
base_config: Dict[str, Any] = {
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"top_p": self.top_p,
"top_k": self.top_k,
}
# Only include top_p when explicitly set by the user.
if self.top_p is not None:
base_config["top_p"] = self.top_p
# Add custom model kwargs
base_config.update(self.model_kwargs)
+10 -7
View File
@@ -2,6 +2,8 @@ from typing import Any, ClassVar, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from mem0.vector_stores.configs import get_default_vector_store_path
class ChromaDbConfig(BaseModel):
try:
@@ -23,24 +25,25 @@ class ChromaDbConfig(BaseModel):
def check_connection_config(cls, values):
host, port, path = values.get("host"), values.get("port"), values.get("path")
api_key, tenant = values.get("api_key"), values.get("tenant")
default_path = get_default_vector_store_path("chroma")
# Check if cloud configuration is provided
cloud_config = bool(api_key and tenant)
# If cloud configuration is provided, remove any default path that might have been added
if cloud_config and path == "/tmp/chroma":
if cloud_config and path == default_path:
values.pop("path", None)
return values
# Check if local/server configuration is provided
local_config = bool(path) or bool(host and port)
if not cloud_config and not local_config:
raise ValueError("Either ChromaDB Cloud configuration (api_key, tenant) or local configuration (path or host/port) must be provided.")
if cloud_config and local_config:
raise ValueError("Cannot specify both cloud configuration and local configuration. Choose one.")
return values
@model_validator(mode="before")
+3 -1
View File
@@ -2,6 +2,8 @@ from typing import Any, ClassVar, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from mem0.vector_stores.configs import get_default_vector_store_path
class QdrantConfig(BaseModel):
from qdrant_client import QdrantClient
@@ -13,7 +15,7 @@ class QdrantConfig(BaseModel):
client: Optional[QdrantClient] = Field(None, description="Existing Qdrant client instance")
host: Optional[str] = Field(None, description="Host address for Qdrant server")
port: Optional[int] = Field(None, description="Port for Qdrant server")
path: Optional[str] = Field("/tmp/qdrant", description="Path for local Qdrant database")
path: Optional[str] = Field(default_factory=lambda: get_default_vector_store_path("qdrant"), description="Path for local Qdrant database")
url: Optional[str] = Field(None, description="Full URL for Qdrant server")
api_key: Optional[str] = Field(None, description="API key for Qdrant server")
on_disk: Optional[bool] = Field(False,description="Enables persistent storage. Vectors are kept on disk (True) or in memory (False). Does not delete the local database path.")
+43 -41
View File
@@ -228,6 +228,12 @@ class AWSBedrockLLM(LLMBase):
return "\n\nHuman: " + "".join(formatted_messages) + "\n\nAssistant:"
def _merge_optional_top_p(self, target: Dict[str, Any], *, key: str = "top_p") -> None:
"""Add nucleus sampling to ``target`` only when ``model_config`` has ``top_p`` set."""
top_p = self.model_config.get("top_p")
if top_p is not None:
target[key] = top_p
def _prepare_input(self, prompt: str) -> Dict[str, Any]:
"""
Prepare input for the current provider's model.
@@ -268,44 +274,38 @@ class AWSBedrockLLM(LLMBase):
"messages": [{"role": "user", "content": prompt}],
"max_tokens": self.model_config.get("max_tokens", 5000),
"temperature": self.model_config.get("temperature", 0.1),
"top_p": self.model_config.get("top_p", 0.9),
}
self._merge_optional_top_p(input_body)
else:
# Legacy Amazon models
input_body = {
"inputText": prompt,
"textGenerationConfig": {
"maxTokenCount": self.model_config.get("max_tokens", 5000),
"topP": self.model_config.get("top_p", 0.9),
"temperature": self.model_config.get("temperature", 0.1),
},
}
# Remove None values
input_body["textGenerationConfig"] = {
k: v for k, v in input_body["textGenerationConfig"].items() if v is not None
text_gen_config: Dict[str, Any] = {
"maxTokenCount": self.model_config.get("max_tokens", 5000),
"temperature": self.model_config.get("temperature", 0.1),
}
self._merge_optional_top_p(text_gen_config, key="topP")
input_body = {"inputText": prompt, "textGenerationConfig": text_gen_config}
elif self.provider == "anthropic":
input_body = {
"messages": [{"role": "user", "content": [{"type": "text", "text": prompt}]}],
"max_tokens": self.model_config.get("max_tokens", 2000),
"temperature": self.model_config.get("temperature", 0.1),
"top_p": self.model_config.get("top_p", 0.9),
"anthropic_version": "bedrock-2023-05-31",
}
self._merge_optional_top_p(input_body)
elif self.provider == "meta":
input_body = {
"prompt": prompt,
"max_gen_len": self.model_config.get("max_tokens", 5000),
"temperature": self.model_config.get("temperature", 0.1),
"top_p": self.model_config.get("top_p", 0.9),
}
self._merge_optional_top_p(input_body)
elif self.provider == "mistral":
input_body = {
"prompt": prompt,
"max_tokens": self.model_config.get("max_tokens", 5000),
"temperature": self.model_config.get("temperature", 0.1),
"top_p": self.model_config.get("top_p", 0.9),
}
self._merge_optional_top_p(input_body)
else:
# Generic case - add all model config parameters
input_body.update(self.model_config)
@@ -479,6 +479,29 @@ class AWSBedrockLLM(LLMBase):
return converse_tools
def _default_max_tokens_for_converse(self) -> int:
"""Default maxTokens if ``max_tokens`` is missing (Nova: 5000, else 2000)."""
model_id = (self.config.model or "").lower()
if self.provider == "amazon" and "nova" in model_id:
return 5000
return 2000
def _build_inference_config(self) -> Dict[str, Any]:
"""Build Converse ``inferenceConfig``. Anthropic allows only one of temperature or topP; we keep temperature and omit topP."""
inference_config: Dict[str, Any] = {
"maxTokens": self.model_config.get("max_tokens", self._default_max_tokens_for_converse()),
"temperature": self.model_config.get("temperature", 0.1),
}
top_p = self.model_config.get("top_p")
if top_p is not None:
if self.provider == "anthropic":
logger.debug("Omitting topP for Anthropic Converse (using temperature); top_p=%s", top_p)
else:
inference_config["topP"] = top_p
return inference_config
def _generate_with_tools(self, messages: List[Dict[str, str]], tools: List[Dict], stream: bool = False) -> Dict[str, Any]:
"""Generate response with tool calling support using correct message format."""
# Format messages for tool-enabled models
@@ -501,11 +524,7 @@ class AWSBedrockLLM(LLMBase):
converse_params = {
"modelId": self.config.model,
"messages": formatted_messages,
"inferenceConfig": {
"maxTokens": self.model_config.get("max_tokens", 2000),
"temperature": self.model_config.get("temperature", 0.1),
"topP": self.model_config.get("top_p", 0.9),
}
"inferenceConfig": self._build_inference_config(),
}
# Add system message if present (for Anthropic)
@@ -531,11 +550,7 @@ class AWSBedrockLLM(LLMBase):
converse_params = {
"modelId": self.config.model,
"messages": formatted_messages,
"inferenceConfig": {
"maxTokens": self.model_config.get("max_tokens", 2000),
"temperature": self.model_config.get("temperature", 0.1),
"topP": self.model_config.get("top_p", 0.9),
}
"inferenceConfig": self._build_inference_config(),
}
# Add system message if present
@@ -554,26 +569,13 @@ class AWSBedrockLLM(LLMBase):
return str(response)
elif self.provider == "amazon" and "nova" in self.config.model.lower():
# Nova models use converse API even without tools
# Nova models use the Converse API even without tools
formatted_messages = self._format_messages_amazon(messages)
input_body = {
"messages": formatted_messages,
"max_tokens": self.model_config.get("max_tokens", 5000),
"temperature": self.model_config.get("temperature", 0.1),
"top_p": self.model_config.get("top_p", 0.9),
}
# Use converse API for Nova models
response = self.client.converse(
modelId=self.config.model,
messages=input_body["messages"],
inferenceConfig={
"maxTokens": input_body["max_tokens"],
"temperature": input_body["temperature"],
"topP": input_body["top_p"],
}
messages=formatted_messages,
inferenceConfig=self._build_inference_config(),
)
return self._parse_response(response)
else:
# For other providers and legacy Amazon models (like Titan)
+61 -31
View File
@@ -9,7 +9,7 @@ import uuid
import warnings
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Union
from pydantic import ValidationError
@@ -479,7 +479,8 @@ class Memory(MemoryBase):
msg_content = message_dict["content"]
msg_embeddings = self.embedding_model.embed(msg_content, "add")
mem_id = self._create_memory(msg_content, msg_embeddings, per_msg_meta)
# Pass embeddings as a dict so _create_memory can reuse the cached embedding
mem_id = self._create_memory(msg_content, {msg_content: msg_embeddings}, per_msg_meta)
returned_memories.append(
{
@@ -608,6 +609,9 @@ class Memory(MemoryBase):
event_type = resp.get("event")
if event_type == "ADD":
# Ensure action_text has an embedding cached to avoid redundant API calls
if action_text not in new_message_embeddings:
new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "add")
memory_id = self._create_memory(
data=action_text,
existing_embeddings=new_message_embeddings,
@@ -615,6 +619,9 @@ class Memory(MemoryBase):
)
returned_memories.append({"id": memory_id, "memory": action_text, "event": event_type})
elif event_type == "UPDATE":
# Ensure action_text has an embedding cached to avoid redundant API calls
if action_text not in new_message_embeddings:
new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "update")
self._update_memory(
memory_id=temp_uuid_mapping[resp.get("id")],
data=action_text,
@@ -1173,31 +1180,34 @@ class Memory(MemoryBase):
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "sync"})
return self.db.get_history(memory_id)
def _create_memory(self, data, existing_embeddings, metadata=None):
def _create_memory(self, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
logger.debug(f"Creating memory with {data=}")
if data in existing_embeddings:
# existing_embeddings may be a dict (preferred) or a precomputed vector
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
embeddings = existing_embeddings[data]
elif not isinstance(existing_embeddings, dict):
embeddings = existing_embeddings
else:
embeddings = self.embedding_model.embed(data, memory_action="add")
memory_id = str(uuid.uuid4())
metadata = metadata or {}
metadata["data"] = data
metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
metadata["created_at"] = datetime.now(timezone.utc).isoformat()
new_metadata = deepcopy(metadata) if metadata is not None else {}
new_metadata["data"] = data
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
self.vector_store.insert(
vectors=[embeddings],
ids=[memory_id],
payloads=[metadata],
payloads=[new_metadata],
)
self.db.add_history(
memory_id,
None,
data,
"ADD",
created_at=metadata.get("created_at"),
actor_id=metadata.get("actor_id"),
role=metadata.get("role"),
created_at=new_metadata.get("created_at"),
actor_id=new_metadata.get("actor_id"),
role=new_metadata.get("role"),
)
return memory_id
@@ -1231,16 +1241,17 @@ class Memory(MemoryBase):
if metadata is None:
raise ValueError("Metadata cannot be done for procedural memory.")
metadata["memory_type"] = MemoryType.PROCEDURAL.value
new_metadata = deepcopy(metadata)
new_metadata["memory_type"] = MemoryType.PROCEDURAL.value
embeddings = self.embedding_model.embed(procedural_memory, memory_action="add")
memory_id = self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=metadata)
memory_id = self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=new_metadata)
capture_event("mem0._create_procedural_memory", self, {"memory_id": memory_id, "sync_type": "sync"})
result = {"results": [{"id": memory_id, "memory": procedural_memory, "event": "ADD"}]}
return result
def _update_memory(self, memory_id, data, existing_embeddings, metadata=None):
def _update_memory(self, memory_id, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
logger.info(f"Updating memory with {data=}")
try:
@@ -1273,8 +1284,10 @@ class Memory(MemoryBase):
if "role" not in new_metadata and "role" in existing_memory.payload:
new_metadata["role"] = existing_memory.payload["role"]
if data in existing_embeddings:
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
embeddings = existing_embeddings[data]
elif not isinstance(existing_embeddings, dict):
embeddings = existing_embeddings
else:
embeddings = self.embedding_model.embed(data, "update")
@@ -1544,7 +1557,8 @@ class AsyncMemory(MemoryBase):
msg_content = message_dict["content"]
msg_embeddings = await asyncio.to_thread(self.embedding_model.embed, msg_content, "add")
mem_id = await self._create_memory(msg_content, msg_embeddings, per_msg_meta)
# Pass embeddings as a dict so _create_memory can reuse the cached embedding
mem_id = await self._create_memory(msg_content, {msg_content: msg_embeddings}, per_msg_meta)
returned_memories.append(
{
@@ -1672,6 +1686,11 @@ class AsyncMemory(MemoryBase):
event_type = resp.get("event")
if event_type == "ADD":
# Ensure action_text has an embedding cached to avoid redundant API calls
if action_text not in new_message_embeddings:
new_message_embeddings[action_text] = await asyncio.to_thread(
self.embedding_model.embed, action_text, "add"
)
task = asyncio.create_task(
self._create_memory(
data=action_text,
@@ -1681,6 +1700,11 @@ class AsyncMemory(MemoryBase):
)
memory_tasks.append((task, resp, "ADD", None))
elif event_type == "UPDATE":
# Ensure action_text has an embedding cached to avoid redundant API calls
if action_text not in new_message_embeddings:
new_message_embeddings[action_text] = await asyncio.to_thread(
self.embedding_model.embed, action_text, "update"
)
task = asyncio.create_task(
self._update_memory(
memory_id=temp_uuid_mapping[resp["id"]],
@@ -2270,24 +2294,27 @@ class AsyncMemory(MemoryBase):
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "async"})
return await asyncio.to_thread(self.db.get_history, memory_id)
async def _create_memory(self, data, existing_embeddings, metadata=None):
async def _create_memory(self, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
logger.debug(f"Creating memory with {data=}")
if data in existing_embeddings:
# existing_embeddings may be a dict (preferred) or a precomputed vector
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
embeddings = existing_embeddings[data]
elif not isinstance(existing_embeddings, dict):
embeddings = existing_embeddings
else:
embeddings = await asyncio.to_thread(self.embedding_model.embed, data, memory_action="add")
memory_id = str(uuid.uuid4())
metadata = metadata or {}
metadata["data"] = data
metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
metadata["created_at"] = datetime.now(timezone.utc).isoformat()
new_metadata = deepcopy(metadata) if metadata is not None else {}
new_metadata["data"] = data
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
await asyncio.to_thread(
self.vector_store.insert,
vectors=[embeddings],
ids=[memory_id],
payloads=[metadata],
payloads=[new_metadata],
)
await asyncio.to_thread(
@@ -2296,9 +2323,9 @@ class AsyncMemory(MemoryBase):
None,
data,
"ADD",
created_at=metadata.get("created_at"),
actor_id=metadata.get("actor_id"),
role=metadata.get("role"),
created_at=new_metadata.get("created_at"),
actor_id=new_metadata.get("actor_id"),
role=new_metadata.get("role"),
)
return memory_id
@@ -2347,16 +2374,17 @@ class AsyncMemory(MemoryBase):
if metadata is None:
raise ValueError("Metadata cannot be done for procedural memory.")
metadata["memory_type"] = MemoryType.PROCEDURAL.value
new_metadata = deepcopy(metadata)
new_metadata["memory_type"] = MemoryType.PROCEDURAL.value
embeddings = await asyncio.to_thread(self.embedding_model.embed, procedural_memory, memory_action="add")
memory_id = await self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=metadata)
memory_id = await self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=new_metadata)
capture_event("mem0._create_procedural_memory", self, {"memory_id": memory_id, "sync_type": "async"})
result = {"results": [{"id": memory_id, "memory": procedural_memory, "event": "ADD"}]}
return result
async def _update_memory(self, memory_id, data, existing_embeddings, metadata=None):
async def _update_memory(self, memory_id, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
logger.info(f"Updating memory with {data=}")
try:
@@ -2390,8 +2418,10 @@ class AsyncMemory(MemoryBase):
if "role" not in new_metadata and "role" in existing_memory.payload:
new_metadata["role"] = existing_memory.payload["role"]
if data in existing_embeddings:
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
embeddings = existing_embeddings[data]
elif not isinstance(existing_embeddings, dict):
embeddings = existing_embeddings
else:
embeddings = await asyncio.to_thread(self.embedding_model.embed, data, "update")
+2 -1
View File
@@ -3,6 +3,7 @@ from typing import Dict, Optional, Union
from mem0.configs.embeddings.base import BaseEmbedderConfig
from mem0.configs.llms.anthropic import AnthropicConfig
from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
from mem0.configs.llms.azure import AzureOpenAIConfig
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.deepseek import DeepSeekConfig
@@ -38,7 +39,7 @@ class LlmFactory:
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
"groq": ("mem0.llms.groq.GroqLLM", BaseLlmConfig),
"together": ("mem0.llms.together.TogetherLLM", BaseLlmConfig),
"aws_bedrock": ("mem0.llms.aws_bedrock.AWSBedrockLLM", BaseLlmConfig),
"aws_bedrock": ("mem0.llms.aws_bedrock.AWSBedrockLLM", AWSBedrockConfig),
"litellm": ("mem0.llms.litellm.LiteLLM", BaseLlmConfig),
"azure_openai": ("mem0.llms.azure_openai.AzureOpenAILLM", AzureOpenAIConfig),
"openai_structured": ("mem0.llms.openai_structured.OpenAIStructuredLLM", OpenAIConfig),
+83 -1
View File
@@ -1,7 +1,88 @@
import logging
import os
import shutil
from pathlib import Path
from typing import Dict, Optional
from pydantic import BaseModel, Field, model_validator
logger = logging.getLogger(__name__)
def get_default_vector_store_path(provider: str) -> str:
"""Return a platform-appropriate default path for a vector store provider.
Preference order: MEM0_DATA_DIR → XDG_DATA_HOME (Linux) → APPDATA (Windows) → ~/.local/share
"""
mem0_data_dir = os.environ.get("MEM0_DATA_DIR")
if mem0_data_dir:
base = Path(mem0_data_dir)
else:
xdg = os.environ.get("XDG_DATA_HOME")
app_data = os.environ.get("APPDATA")
if xdg:
base = Path(xdg)
elif app_data:
base = Path(app_data)
else:
base = Path.home() / ".local" / "share"
base = base / "mem0"
return str(base / provider)
def _migrate_legacy_data(provider: str, new_path: str, legacy_path: Optional[str] = None):
"""Auto-migrate data from legacy /tmp/{provider} to the new persistent path.
Skips migration if:
- Legacy path doesn't exist, is empty, or is a symlink (security)
- New path already contains data (no clobbering)
- A .migration_complete sentinel exists at the new path (already migrated)
Falls back to a warning with manual instructions if the copy fails.
Note: On Windows /tmp doesn't exist, so this is effectively a no-op.
Args:
provider: Vector store provider name (e.g. 'qdrant').
new_path: The new persistent path to migrate data to.
legacy_path: Override the legacy path (default: /tmp/{provider}).
Exposed for testing; production callers should omit this.
"""
legacy_path = Path(legacy_path if legacy_path is not None else f"/tmp/{provider}")
if not legacy_path.exists() or legacy_path.is_symlink() or not any(legacy_path.iterdir()):
return
new_path_obj = Path(new_path)
# Skip if already migrated in a previous run
sentinel = new_path_obj / ".migration_complete"
if sentinel.exists():
return
if new_path_obj.exists() and any(new_path_obj.iterdir()):
logger.info(
"Both legacy path '%s' and new path '%s' contain data. "
"Using new path. Legacy data at '%s' is untouched.",
legacy_path, new_path, legacy_path,
)
return
try:
shutil.copytree(str(legacy_path), new_path, dirs_exist_ok=True)
# Write sentinel to prevent repeated migration attempts on future startups
sentinel.touch()
logger.info(
"Migrated vector store data from '%s' to '%s'. "
"You may remove the old directory once verified.",
legacy_path, new_path,
)
except Exception as e:
logger.warning(
"Could not auto-migrate data from '%s' to '%s': %s. "
"Please manually copy/move the data, or set path='/tmp/%s' in your config "
"to keep using the old location.",
legacy_path, new_path, e, provider,
)
class VectorStoreConfig(BaseModel):
provider: str = Field(
@@ -61,7 +142,8 @@ class VectorStoreConfig(BaseModel):
# also check if path in allowed kays for pydantic model, and whether config extra fields are allowed
if "path" not in config and "path" in config_class.__annotations__:
config["path"] = f"/tmp/{provider}"
config["path"] = get_default_vector_store_path(provider)
_migrate_legacy_data(provider, config["path"])
self.config = config_class(**config)
return self
+3 -1
View File
@@ -8,6 +8,8 @@ from typing import Dict, List, Optional
import numpy as np
from pydantic import BaseModel
from mem0.vector_stores.configs import get_default_vector_store_path
import warnings
try:
@@ -58,7 +60,7 @@ class FAISS(VectorStoreBase):
Defaults to False.
"""
self.collection_name = collection_name
self.path = path or f"/tmp/faiss/{collection_name}"
self.path = path or f"{get_default_vector_store_path('faiss')}/{collection_name}"
self.distance_strategy = distance_strategy
self.normalize_L2 = normalize_L2
self.embedding_model_dims = embedding_model_dims
+2 -1
View File
@@ -197,7 +197,8 @@ class MongoDB(VectorStoreBase):
if vector is not None:
update_fields["embedding"] = vector
if payload is not None:
update_fields["payload"] = payload
for key, value in payload.items():
update_fields[f"payload.{key}"] = value
if update_fields:
try:
+86 -4
View File
@@ -21,6 +21,8 @@ import json
import logging
import uuid
import anyio
from app.database import SessionLocal
from app.models import Memory, MemoryAccessLog, MemoryState, MemoryStatusHistory
from app.utils.db import get_user_and_app
@@ -31,6 +33,8 @@ from fastapi import FastAPI, Request
from fastapi.routing import APIRouter
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import StreamableHTTPServerTransport
from starlette.responses import Response
# Load environment variables
load_dotenv()
@@ -57,8 +61,8 @@ mcp_router = APIRouter(prefix="/mcp")
# Initialize SSE transport
sse = SseServerTransport("/mcp/messages/")
@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something.")
async def add_memories(text: str) -> str:
@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something. Set infer to False to store the memory verbatim without LLM fact extraction.")
async def add_memories(text: str, infer: bool = True) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
@@ -87,7 +91,8 @@ async def add_memories(text: str) -> str:
metadata={
"source_app": "openmemory",
"mcp_client": client_name,
})
},
infer=infer)
# Process the response and update database
if isinstance(response, dict) and 'results' in response:
@@ -437,7 +442,10 @@ async def handle_sse(request: Request):
client_token = client_name_var.set(client_name or "")
try:
# Handle SSE connection
# NOTE: request._send is the raw ASGI `send` callable. Starlette does not
# expose it publicly, but the MCP SDK transports require the raw ASGI
# interface (scope, receive, send). This is the standard pattern from the
# MCP Python SDK examples.
async with sse.connect_sse(
request.scope,
request.receive,
@@ -484,6 +492,80 @@ async def handle_post_message(request: Request):
finally:
pass
@mcp_router.api_route("/{client_name}/http/{user_id}", methods=["POST", "GET", "DELETE"])
async def handle_streamable_http(request: Request):
"""Handle Streamable HTTP connections for a specific user and client.
Uses the Streamable HTTP transport (MCP spec 2025-03-26+) which replaces
the deprecated SSE transport. Runs in stateless mode — each request is
handled independently with no persistent session.
The transport writes its response directly to the ASGI ``send`` callable.
We intercept it via ``capture_send`` so we can return a proper ``Response``
to FastAPI — otherwise FastAPI would also try to send its own response,
causing a "double-response" bug.
"""
uid = request.path_params.get("user_id")
user_token = user_id_var.set(uid or "")
client_name = request.path_params.get("client_name")
client_token = client_name_var.set(client_name or "")
# Intercept the ASGI messages the transport sends so we can return them
# as a single Response to FastAPI. Without this, FastAPI would attempt to
# write its own response after the transport already wrote one.
response_started = False
response_status = 200
response_headers: list[tuple[bytes, bytes]] = []
response_body = bytearray()
async def capture_send(message):
nonlocal response_started, response_status
if message["type"] == "http.response.start":
response_started = True
response_status = message["status"]
response_headers.extend(message.get("headers", []))
elif message["type"] == "http.response.body":
response_body.extend(message.get("body", b""))
try:
transport = StreamableHTTPServerTransport(
mcp_session_id=None,
is_json_response_enabled=True,
)
async with anyio.create_task_group() as tg:
async def run_server(*, task_status=anyio.TASK_STATUS_IGNORED):
async with transport.connect() as (read_stream, write_stream):
task_status.started()
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options(),
stateless=True,
)
await tg.start(run_server)
await transport.handle_request(request.scope, request.receive, capture_send)
await transport.terminate()
tg.cancel_scope.cancel()
finally:
user_id_var.reset(user_token)
client_name_var.reset(client_token)
if not response_started:
return Response(status_code=500, content=b"Transport did not produce a response")
# Header dict conversion is safe here: the MCP transport in stateless JSON
# mode only emits single-valued headers (Content-Type, Content-Length).
return Response(
content=bytes(response_body),
status_code=response_status,
headers={k.decode(): v.decode() for k, v in response_headers},
)
def setup_mcp_server(app: FastAPI):
"""Setup MCP server with the FastAPI application"""
mcp._mcp_server.name = "mem0-mcp-server"
View File
+396
View File
@@ -0,0 +1,396 @@
"""Tests for the MCP server endpoints (SSE and Streamable HTTP transports).
Covers the Streamable HTTP transport (MCP spec 2025-03-26+) and the legacy SSE
transport. Tests exercise the full JSON-RPC flow — initialize, tools/list,
tools/call — as well as error handling and context-variable isolation.
"""
import os
# Set dummy keys before any imports that trigger client initialization
os.environ.setdefault("OPENAI_API_KEY", "test-key")
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.mcp_server import client_name_var, mcp, mcp_router, user_id_var
# MCP Streamable HTTP requires the Accept header to include application/json.
# Including text/event-stream as well satisfies GET (SSE) requests.
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def test_app():
"""Create a minimal FastAPI app with just the MCP router for testing."""
from fastapi import FastAPI
app = FastAPI()
app.include_router(mcp_router)
return app
@pytest_asyncio.fixture
async def client(test_app):
"""Async HTTP client wired to the test app via ASGI transport."""
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _jsonrpc(method: str, params: dict | None = None, req_id: int = 1) -> dict:
"""Build a JSON-RPC 2.0 request envelope."""
return {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params or {},
}
def _initialize_payload(req_id: int = 1) -> dict:
return _jsonrpc(
"initialize",
{
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "0.1.0"},
},
req_id=req_id,
)
# ---------------------------------------------------------------------------
# Streamable HTTP — route existence & basic protocol
# ---------------------------------------------------------------------------
class TestStreamableHTTPBasic:
"""Verify the Streamable HTTP route is registered and responds."""
@pytest.mark.asyncio
async def test_post_initialize(self, client):
"""POST initialize should return a valid JSON-RPC result."""
resp = await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["jsonrpc"] == "2.0"
assert data["id"] == 1
assert "result" in data
result = data["result"]
assert "serverInfo" in result
assert "capabilities" in result
assert result["protocolVersion"] == "2025-03-26"
@pytest.mark.asyncio
async def test_delete_returns_method_not_allowed(self, client):
"""DELETE in stateless mode should return 405 (no session to terminate)."""
resp = await client.delete(
"/mcp/testclient/http/user1",
headers=MCP_HEADERS,
)
assert resp.status_code == 405
@pytest.mark.asyncio
async def test_missing_accept_header_returns_406(self, client):
"""POST without the required Accept header should return 406."""
resp = await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
)
assert resp.status_code == 406
@pytest.mark.asyncio
async def test_invalid_json_returns_400(self, client):
"""POST with unparseable body should return 400."""
resp = await client.post(
"/mcp/testclient/http/user1",
content=b"not json",
headers={**MCP_HEADERS, "Content-Type": "application/json"},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_route_not_found_for_wrong_path(self, client):
"""Requests to a non-existent path should 404."""
resp = await client.post(
"/mcp/testclient/nonexistent/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Streamable HTTP — full protocol flow
# ---------------------------------------------------------------------------
class TestStreamableHTTPProtocol:
"""End-to-end JSON-RPC flows over Streamable HTTP."""
@pytest.mark.asyncio
async def test_tools_list(self, client):
"""tools/list should return all registered MCP tools."""
init_resp = await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
assert init_resp.status_code == 200
resp = await client.post(
"/mcp/testclient/http/user1",
json=_jsonrpc("tools/list", req_id=2),
headers=MCP_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert "result" in data
tool_names = {t["name"] for t in data["result"]["tools"]}
expected = {"add_memories", "search_memory", "list_memories",
"delete_memories", "delete_all_memories"}
assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}"
@pytest.mark.asyncio
async def test_tools_list_has_descriptions(self, client):
"""Every tool returned by tools/list should have a non-empty description."""
await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
resp = await client.post(
"/mcp/testclient/http/user1",
json=_jsonrpc("tools/list", req_id=2),
headers=MCP_HEADERS,
)
for tool in resp.json()["result"]["tools"]:
assert tool.get("description"), f"Tool {tool['name']} has no description"
@pytest.mark.asyncio
async def test_tools_list_has_input_schemas(self, client):
"""Every tool should declare an inputSchema."""
await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
resp = await client.post(
"/mcp/testclient/http/user1",
json=_jsonrpc("tools/list", req_id=2),
headers=MCP_HEADERS,
)
for tool in resp.json()["result"]["tools"]:
assert "inputSchema" in tool, f"Tool {tool['name']} missing inputSchema"
@pytest.mark.asyncio
async def test_call_unknown_tool_returns_error(self, client):
"""Calling a non-existent tool should return a JSON-RPC error."""
await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
resp = await client.post(
"/mcp/testclient/http/user1",
json=_jsonrpc("tools/call", {"name": "no_such_tool", "arguments": {}}, req_id=2),
headers=MCP_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert "error" in data or (
"result" in data and data["result"].get("isError")
)
@pytest.mark.asyncio
async def test_unknown_jsonrpc_method(self, client):
"""An unknown JSON-RPC method should return an error."""
resp = await client.post(
"/mcp/testclient/http/user1",
json=_jsonrpc("nonexistent/method"),
headers=MCP_HEADERS,
)
assert resp.status_code in (200, 400)
@pytest.mark.asyncio
async def test_response_content_type_is_json(self, client):
"""Responses should have Content-Type: application/json."""
resp = await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
ct = resp.headers.get("content-type", "")
assert "application/json" in ct
# ---------------------------------------------------------------------------
# Streamable HTTP — context variable handling
# ---------------------------------------------------------------------------
class TestStreamableHTTPContext:
"""Verify that user_id and client_name context variables are set correctly."""
@pytest.mark.asyncio
async def test_context_vars_set_during_tool_call(self, client):
"""Context vars should reflect the path parameters during tool execution."""
captured = {}
@mcp.tool(name="__test_ctx", description="test only")
async def _capture(query: str = "") -> str:
captured["user_id"] = user_id_var.get(None)
captured["client_name"] = client_name_var.get(None)
return "ok"
try:
await client.post(
"/mcp/my-app/http/alice",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
resp = await client.post(
"/mcp/my-app/http/alice",
json=_jsonrpc("tools/call", {"name": "__test_ctx", "arguments": {}}, req_id=2),
headers=MCP_HEADERS,
)
assert resp.status_code == 200
assert captured.get("user_id") == "alice"
assert captured.get("client_name") == "my-app"
finally:
mcp._tool_manager._tools.pop("__test_ctx", None)
@pytest.mark.asyncio
async def test_different_users_are_isolated(self, client):
"""Sequential requests with different user_ids must not leak state."""
results = []
@mcp.tool(name="__test_uid_iso", description="test only")
async def _capture_uid(query: str = "") -> str:
results.append(user_id_var.get(None))
return "ok"
try:
for uid in ("userA", "userB", "userC"):
await client.post(
f"/mcp/app1/http/{uid}",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
await client.post(
f"/mcp/app1/http/{uid}",
json=_jsonrpc("tools/call", {"name": "__test_uid_iso", "arguments": {}}, req_id=2),
headers=MCP_HEADERS,
)
assert results == ["userA", "userB", "userC"]
finally:
mcp._tool_manager._tools.pop("__test_uid_iso", None)
@pytest.mark.asyncio
async def test_different_clients_are_isolated(self, client):
"""Sequential requests with different client_names must not leak state."""
results = []
@mcp.tool(name="__test_cn_iso", description="test only")
async def _capture_cn(query: str = "") -> str:
results.append(client_name_var.get(None))
return "ok"
try:
for cn in ("cursor", "windsurf", "claude"):
await client.post(
f"/mcp/{cn}/http/user1",
json=_initialize_payload(),
headers=MCP_HEADERS,
)
await client.post(
f"/mcp/{cn}/http/user1",
json=_jsonrpc("tools/call", {"name": "__test_cn_iso", "arguments": {}}, req_id=2),
headers=MCP_HEADERS,
)
assert results == ["cursor", "windsurf", "claude"]
finally:
mcp._tool_manager._tools.pop("__test_cn_iso", None)
# ---------------------------------------------------------------------------
# Streamable HTTP — response correctness
# ---------------------------------------------------------------------------
class TestStreamableHTTPResponses:
"""Verify that captured responses are returned correctly to the caller."""
@pytest.mark.asyncio
async def test_error_status_codes_are_preserved(self, client):
"""Transport error codes (e.g. 406) must be forwarded, not masked as 200."""
resp = await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(),
)
assert resp.status_code == 406
@pytest.mark.asyncio
async def test_delete_status_code_preserved(self, client):
"""DELETE 405 from stateless transport must not be masked."""
resp = await client.delete(
"/mcp/testclient/http/user1",
headers=MCP_HEADERS,
)
assert resp.status_code == 405
@pytest.mark.asyncio
async def test_multiple_sequential_requests(self, client):
"""Multiple requests in sequence should each get independent responses."""
for i in range(5):
resp = await client.post(
"/mcp/testclient/http/user1",
json=_initialize_payload(req_id=i + 1),
headers=MCP_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == i + 1
assert "result" in data
@pytest.mark.asyncio
async def test_wrong_content_type_returns_error(self, client):
"""POST with wrong Content-Type should return an error status."""
resp = await client.post(
"/mcp/testclient/http/user1",
content=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
headers={**MCP_HEADERS, "Content-Type": "text/plain"},
)
assert resp.status_code in (400, 415)
# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------
class TestRouteRegistration:
"""Verify all expected routes are registered in the router."""
def test_sse_route_is_registered(self, test_app):
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
assert "/mcp/{client_name}/sse/{user_id}" in routes
def test_sse_post_messages_route_is_registered(self, test_app):
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
assert "/mcp/messages/" in routes or "/mcp/{client_name}/sse/{user_id}/messages/" in routes
def test_streamable_http_route_is_registered(self, test_app):
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
assert "/mcp/{client_name}/http/{user_id}" in routes
+11 -16
View File
@@ -349,23 +349,18 @@ Use the `name` field in messages to identify speakers. Mem0 maps names to entity
## MCP Integration
Model Context Protocol integration enables AI clients (Claude Desktop, Cursor, custom agents) to manage Mem0 memory autonomously.
Model Context Protocol integration enables AI clients (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode) to manage Mem0 memory autonomously.
### Configuration
### Setup
```json
{
"mcpServers": {
"mem0": {
"command": "uvx",
"args": ["mem0-mcp-server"],
"env": {
"MEM0_API_KEY": "m0-your-api-key",
"MEM0_DEFAULT_USER_ID": "your-user-id"
}
}
}
}
Add Mem0 MCP to your clients with a single command:
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp" \
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
```
### Available MCP Tools
@@ -377,7 +372,7 @@ The MCP server exposes 9 memory tools that AI agents can use autonomously:
### How It Works
1. Configure the MCP server in your AI client
1. Add Mem0 MCP to your AI client using the setup command above
2. The agent autonomously decides when to store/retrieve memories
3. No manual API calls needed — the agent manages memory as part of its reasoning
-4
View File
@@ -62,10 +62,6 @@ SECTION_MAP = {
"/open-source/features/rest-api",
"/open-source/configure-components",
],
"openmemory": [
"/openmemory/overview",
"/openmemory/quickstart",
],
"sdks": [
"/sdks/python",
"/sdks/js",
+330
View File
@@ -0,0 +1,330 @@
from unittest.mock import MagicMock, patch
import pytest
from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
from mem0.llms.aws_bedrock import AWSBedrockLLM, extract_provider
from mem0.utils.factory import LlmFactory
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_boto3():
"""Patch boto3 so no real AWS calls are made during unit tests."""
with patch("mem0.llms.aws_bedrock.boto3") as mock_b3:
runtime_client = MagicMock()
bedrock_client = MagicMock()
bedrock_client.list_foundation_models.return_value = {"modelSummaries": []}
def _client(service, **kwargs):
if service == "bedrock-runtime":
return runtime_client
return bedrock_client
mock_b3.client.side_effect = _client
yield runtime_client
def _make_llm(model: str, mock_boto3, **kwargs) -> AWSBedrockLLM:
"""Instantiate AWSBedrockLLM with a given model, all AWS calls mocked."""
config = AWSBedrockConfig(model=model, **kwargs)
return AWSBedrockLLM(config)
def _converse_response(text: str = "ok") -> dict:
"""Minimal Converse API response dict."""
return {"output": {"message": {"content": [{"text": text}]}}}
# ---------------------------------------------------------------------------
# extract_provider
# ---------------------------------------------------------------------------
class TestExtractProvider:
def test_standard_anthropic_model(self):
assert extract_provider("anthropic.claude-3-5-sonnet-20240620-v1:0") == "anthropic"
def test_inference_profile_us_prefix(self):
# Cross-region inference profile IDs look like us.anthropic.<model>
assert extract_provider("us.anthropic.claude-haiku-4-5-20251001-v1:0") == "anthropic"
def test_inference_profile_eu_prefix(self):
assert extract_provider("eu.anthropic.claude-sonnet-4-5-20250929-v1:0") == "anthropic"
def test_inference_profile_ap_prefix(self):
assert extract_provider("ap.anthropic.claude-3-opus-20240229-v1:0") == "anthropic"
def test_amazon_model(self):
assert extract_provider("amazon.nova-3-mini-20241119-v1:0") == "amazon"
def test_meta_model(self):
assert extract_provider("meta.llama3-8b-instruct-v1:0") == "meta"
def test_mistral_model(self):
assert extract_provider("mistral.mistral-7b-instruct-v0:2") == "mistral"
def test_unknown_model_raises(self):
with pytest.raises(ValueError, match="Unknown provider"):
extract_provider("unknown-vendor.some-model-v1:0")
# ---------------------------------------------------------------------------
# AWSBedrockConfig
# ---------------------------------------------------------------------------
class TestAWSBedrockConfig:
def test_top_p_defaults_to_none(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
assert config.top_p is None
def test_top_p_explicit_value_stored(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", top_p=0.8)
assert config.top_p == 0.8
def test_get_model_config_excludes_top_p_by_default(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", temperature=0.5)
model_cfg = config.get_model_config()
assert "top_p" not in model_cfg
def test_get_model_config_includes_top_p_when_set(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", top_p=0.7)
model_cfg = config.get_model_config()
assert model_cfg["top_p"] == 0.7
def test_get_model_config_top_p_via_model_kwargs(self):
"""model_kwargs can supply top_p after merge; same semantics as explicit top_p."""
config = AWSBedrockConfig(
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
model_kwargs={"top_p": 0.88},
)
assert config.get_model_config()["top_p"] == 0.88
def test_get_model_config_always_includes_temperature(self):
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", temperature=0.3)
model_cfg = config.get_model_config()
assert model_cfg["temperature"] == 0.3
def test_aws_region_stored(self):
config = AWSBedrockConfig(
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
aws_region="us-east-2",
)
assert config.aws_region == "us-east-2"
# ---------------------------------------------------------------------------
# LlmFactory
# ---------------------------------------------------------------------------
class TestLlmFactory:
def test_aws_bedrock_uses_aws_bedrock_config(self):
_, config_class = LlmFactory.provider_to_class["aws_bedrock"]
assert config_class is AWSBedrockConfig
def test_factory_create_accepts_aws_region(self, mock_boto3):
"""LlmFactory.create must not crash when aws_region is in the config dict.
Before the fix, the factory mapped aws_bedrock to BaseLlmConfig which has
no aws_region parameter, causing: TypeError: __init__() got an unexpected
keyword argument 'aws_region'.
"""
user_dict = {
"model": "anthropic.claude-3-5-sonnet-20240620-v1:0",
"aws_region": "us-east-2",
"temperature": 0.1,
"max_tokens": 2000,
}
mock_boto3.converse.return_value = _converse_response()
# This must not raise TypeError
llm = LlmFactory.create("aws_bedrock", user_dict)
assert isinstance(llm.config, AWSBedrockConfig)
assert llm.config.aws_region == "us-east-2"
assert llm.config.top_p is None
# ---------------------------------------------------------------------------
# _build_inference_config
# ---------------------------------------------------------------------------
class TestBuildInferenceConfig:
"""
Unit tests for the _build_inference_config helper.
Validates the exact keys present in the returned dict.
"""
def test_anthropic_only_temperature_by_default(self, mock_boto3):
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.5)
cfg = llm._build_inference_config()
assert "temperature" in cfg
assert cfg["temperature"] == 0.5
assert "topP" not in cfg, "topP must be absent when top_p not configured"
def test_anthropic_top_p_explicitly_set_still_omits_top_p(self, mock_boto3):
# Anthropic rejects both; topP must still be omitted even when set
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
temperature=0.5,
top_p=0.9,
)
cfg = llm._build_inference_config()
assert "temperature" in cfg
assert "topP" not in cfg
def test_anthropic_inference_profile_omits_top_p(self, mock_boto3):
# Cross-region inference profiles (us.anthropic.*) follow the same rule
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3, temperature=0.1)
cfg = llm._build_inference_config()
assert "topP" not in cfg
def test_amazon_includes_top_p_when_set(self, mock_boto3):
llm = _make_llm(
"amazon.nova-3-mini-20241119-v1:0",
mock_boto3,
temperature=0.5,
top_p=0.85,
)
cfg = llm._build_inference_config()
assert cfg["temperature"] == 0.5
assert cfg["topP"] == 0.85
def test_amazon_omits_top_p_when_not_set(self, mock_boto3):
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3, temperature=0.5)
cfg = llm._build_inference_config()
assert "topP" not in cfg
def test_max_tokens_present(self, mock_boto3):
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
max_tokens=1024,
)
cfg = llm._build_inference_config()
assert cfg["maxTokens"] == 1024
def test_nova_fallback_max_tokens_when_absent(self, mock_boto3):
"""Legacy Nova Converse used 5000 when max_tokens was missing from the dict."""
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3)
llm.model_config.pop("max_tokens", None)
cfg = llm._build_inference_config()
assert cfg["maxTokens"] == 5000
def test_anthropic_fallback_max_tokens_when_absent(self, mock_boto3):
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
llm.model_config.pop("max_tokens", None)
cfg = llm._build_inference_config()
assert cfg["maxTokens"] == 2000
# ---------------------------------------------------------------------------
# generate_response — Converse API call assertions
# ---------------------------------------------------------------------------
MESSAGES = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
]
TOOLS = [
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Store a memory",
"parameters": {
"type": "object",
"properties": {"data": {"type": "string"}},
"required": ["data"],
},
},
}
]
class TestGenerateResponseConverse:
def test_standard_anthropic_no_top_p_in_converse_call(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.2)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
inference_cfg = kwargs["inferenceConfig"]
assert "topP" not in inference_cfg
assert inference_cfg["temperature"] == 0.2
def test_standard_anthropic_inference_profile_no_top_p(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3, temperature=0.1)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_with_tools_anthropic_no_top_p(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.2)
llm.generate_response(MESSAGES, tools=TOOLS)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_with_tools_anthropic_top_p_set_still_omitted(self, mock_boto3):
# Even if user explicitly sets top_p, Anthropic must not receive topP
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
temperature=0.2,
top_p=0.9,
)
llm.generate_response(MESSAGES, tools=TOOLS)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_nova_includes_top_p_when_explicitly_set(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm(
"amazon.nova-3-mini-20241119-v1:0",
mock_boto3,
temperature=0.5,
top_p=0.85,
)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert kwargs["inferenceConfig"]["topP"] == 0.85
def test_nova_omits_top_p_when_not_set(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3, temperature=0.5)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
def test_anthropic_model_kwargs_top_p_still_omits_top_p_in_converse(self, mock_boto3):
"""top_p injected via model_kwargs must not add topP for Anthropic Converse."""
mock_boto3.converse.return_value = _converse_response()
llm = _make_llm(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
mock_boto3,
temperature=0.2,
model_kwargs={"top_p": 0.88},
)
llm.generate_response(MESSAGES)
_, kwargs = mock_boto3.converse.call_args
assert "topP" not in kwargs["inferenceConfig"]
+195
View File
@@ -188,6 +188,201 @@ async def test_async_update_memory_uses_utc_timestamps(mocker):
_assert_utc_timestamp(payload["updated_at"])
class TestMetadataNotMutated:
"""Tests that metadata dicts passed to memory methods are not mutated in-place (issue #2648)."""
def test_create_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, Memory)
original_metadata = {"user_id": "test_user", "category": "sports"}
metadata_copy = original_metadata.copy()
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"_create_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
def test_create_memory_stores_correct_payload(self, mocker):
memory = _build_memory_instance(mocker, Memory)
metadata = {"user_id": "test_user", "category": "sports"}
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["data"] == "test data"
assert payload["user_id"] == "test_user"
assert payload["category"] == "sports"
assert "hash" in payload
assert "created_at" in payload
def test_create_memory_with_none_metadata(self, mocker):
memory = _build_memory_instance(mocker, Memory)
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=None)
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["data"] == "test data"
assert "hash" in payload
def test_create_memory_shared_metadata_across_calls(self, mocker):
"""Verify that sharing a metadata dict between multiple _create_memory calls is safe."""
memory = _build_memory_instance(mocker, Memory)
shared_metadata = {"user_id": "test_user"}
memory._create_memory("first memory", {"first memory": [0.1, 0.2, 0.3]}, metadata=shared_metadata)
memory._create_memory("second memory", {"second memory": [0.4, 0.5, 0.6]}, metadata=shared_metadata)
assert shared_metadata == {"user_id": "test_user"}, "shared metadata was mutated across calls"
# Verify each call got the correct data
first_payload = memory.vector_store.insert.call_args_list[0].kwargs["payloads"][0]
second_payload = memory.vector_store.insert.call_args_list[1].kwargs["payloads"][0]
assert first_payload["data"] == "first memory"
assert second_payload["data"] == "second memory"
def test_create_memory_preserves_role_and_actor_id_in_history(self, mocker):
"""Verify that role and actor_id from metadata flow through to add_history after deepcopy."""
memory = _build_memory_instance(mocker, Memory)
metadata = {"user_id": "test_user", "role": "assistant", "actor_id": "bot-1"}
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
# Verify the payload stored in vector store has all fields
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
assert payload["role"] == "assistant"
assert payload["actor_id"] == "bot-1"
assert payload["user_id"] == "test_user"
assert payload["data"] == "test data"
# Verify add_history received the correct role and actor_id
history_call = memory.db.add_history.call_args
assert history_call.kwargs["role"] == "assistant"
assert history_call.kwargs["actor_id"] == "bot-1"
# And the original metadata is still untouched
assert metadata == {"user_id": "test_user", "role": "assistant", "actor_id": "bot-1"}
def test_create_memory_with_nested_metadata_not_mutated(self, mocker):
"""Verify deepcopy protects nested structures in metadata."""
memory = _build_memory_instance(mocker, Memory)
metadata = {"user_id": "test_user", "tags": ["important", "urgent"], "config": {"key": "val"}}
import copy
metadata_snapshot = copy.deepcopy(metadata)
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
assert metadata == metadata_snapshot, "Nested metadata structures were mutated"
def test_update_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, Memory)
memory.vector_store.get.return_value = MagicMock(
payload={"data": "old data", "user_id": "test_user", "created_at": "2026-01-01T00:00:00+00:00"}
)
original_metadata = {"category": "updated"}
metadata_copy = original_metadata.copy()
memory._update_memory("mem-id", "new data", {"new data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"_update_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
def test_add_to_vector_store_no_infer_does_not_mutate_metadata(self, mocker):
"""Verify _add_to_vector_store with infer=False doesn't leak metadata between messages."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
original_metadata = {"user_id": "test_user"}
metadata_copy = original_metadata.copy()
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there", "name": "bot-1"},
]
result = memory._add_to_vector_store(messages, original_metadata, filters={}, infer=False)
# Metadata should not be mutated
assert original_metadata == metadata_copy, (
f"_add_to_vector_store mutated the caller's metadata: {original_metadata}"
)
# Should have created 2 memories
assert len(result) == 2
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
assert result[1]["actor_id"] == "bot-1"
# Verify each insert got distinct payloads with correct roles
insert_calls = memory.vector_store.insert.call_args_list
first_payload = insert_calls[0].kwargs["payloads"][0]
second_payload = insert_calls[1].kwargs["payloads"][0]
assert first_payload["role"] == "user"
assert "actor_id" not in first_payload # user message has no name
assert second_payload["role"] == "assistant"
assert second_payload["actor_id"] == "bot-1"
@pytest.mark.asyncio
async def test_async_create_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
original_metadata = {"user_id": "test_user", "category": "sports"}
metadata_copy = original_metadata.copy()
await memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"async _create_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
@pytest.mark.asyncio
async def test_async_create_memory_shared_metadata_across_calls(self, mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
shared_metadata = {"user_id": "test_user"}
await memory._create_memory("first memory", {"first memory": [0.1, 0.2, 0.3]}, metadata=shared_metadata)
await memory._create_memory("second memory", {"second memory": [0.4, 0.5, 0.6]}, metadata=shared_metadata)
assert shared_metadata == {"user_id": "test_user"}, "shared metadata was mutated across async calls"
@pytest.mark.asyncio
async def test_async_add_to_vector_store_no_infer_does_not_mutate_metadata(self, mocker):
"""Verify async _add_to_vector_store with infer=False doesn't leak metadata between messages."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
original_metadata = {"user_id": "test_user"}
metadata_copy = original_metadata.copy()
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there", "name": "bot-1"},
]
result = await memory._add_to_vector_store(messages, original_metadata, effective_filters={}, infer=False)
assert original_metadata == metadata_copy, (
f"async _add_to_vector_store mutated the caller's metadata: {original_metadata}"
)
assert len(result) == 2
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
@pytest.mark.asyncio
async def test_async_update_memory_does_not_mutate_metadata(self, mocker):
memory = _build_memory_instance(mocker, AsyncMemory)
memory.vector_store.get.return_value = MagicMock(
payload={"data": "old data", "user_id": "test_user", "created_at": "2026-01-01T00:00:00+00:00"}
)
original_metadata = {"category": "updated"}
metadata_copy = original_metadata.copy()
await memory._update_memory("mem-id", "new data", {"new data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
assert original_metadata == metadata_copy, (
f"async _update_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
)
def test_normalize_iso_timestamp_to_utc_preserves_naive_values():
assert _normalize_iso_timestamp_to_utc("2026-03-18T00:00:00") == "2026-03-18T00:00:00"
+134
View File
@@ -438,3 +438,137 @@ async def test_async_update_nonexistent_memory_raises_error(mock_sqlite, mock_ll
await memory._update_memory("non-existent-id", "new data", {"new data": [0.1, 0.2]})
mock_vector_store.update.assert_not_called()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_false_embeds_once(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Regression test for issue #3723: adding with infer=False should not trigger duplicate embedding calls.
Root cause: _create_memory expected a dict for existing_embeddings but received a raw list[float],
causing the cache check `data in existing_embeddings` to always fail and trigger a redundant embed.
"""
embedder = MagicMock()
embedder.embed.return_value = [0.1, 0.2, 0.3]
embedder.config = MagicMock(embedding_dims=3)
mock_embedder_factory.return_value = embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_store.insert.return_value = None
mock_vector_store.get.return_value = None
telemetry_vector_store = MagicMock()
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
memory = MemoryClass(MemoryConfig())
memory.add("foo", user_id="test_user", infer=False)
assert embedder.embed.call_count == 1
mock_vector_store.insert.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Regression test for issue #3723 (infer=True path): when the LLM rewrites a fact during the
ADD action, the embedding should be computed once and cached, not computed again inside _create_memory.
"""
embedder = MagicMock()
embedder.embed.return_value = [0.1, 0.2, 0.3]
embedder.config = MagicMock(embedding_dims=3)
mock_embedder_factory.return_value = embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_store.insert.return_value = None
mock_vector_store.get.return_value = None
telemetry_vector_store = MagicMock()
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
# LLM extracts fact "User likes Python", then ADD action rewrites to "The user enjoys Python"
mock_llm = MagicMock()
mock_llm.generate_response.side_effect = [
json.dumps({"facts": ["User likes Python"]}),
json.dumps({"memory": [{"id": "0", "text": "The user enjoys Python", "event": "ADD", "old_memory": None}]}),
]
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
memory = MemoryClass(MemoryConfig())
memory.add("I like Python", user_id="test_user", infer=True)
# embed should be called exactly twice:
# 1. For the extracted fact "User likes Python" (search)
# 2. For the rewritten text "The user enjoys Python" (pre-cached before _create_memory)
# It should NOT be called a 3rd time inside _create_memory
assert embedder.embed.call_count == 2
mock_vector_store.insert.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Regression test for issue #3723 (infer=True UPDATE path): when the LLM rewrites a fact during
an UPDATE action, the embedding should be computed once and cached, not computed again inside _update_memory.
"""
embedder = MagicMock()
embedder.embed.return_value = [0.1, 0.2, 0.3]
embedder.config = MagicMock(embedding_dims=3)
mock_embedder_factory.return_value = embedder
# Existing memory that will be matched for update
existing_memory = MockVectorMemory(
memory_id="existing-mem-id",
payload={
"data": "User likes Python",
"hash": "abc123",
"created_at": "2025-01-01T00:00:00+00:00",
},
)
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = [existing_memory]
mock_vector_store.get.return_value = existing_memory
mock_vector_store.insert.return_value = None
mock_vector_store.update.return_value = None
telemetry_vector_store = MagicMock()
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
# LLM extracts fact "User loves Python now", then UPDATE action rewrites to "The user loves Python"
mock_llm = MagicMock()
mock_llm.generate_response.side_effect = [
json.dumps({"facts": ["User loves Python now"]}),
json.dumps({"memory": [{"id": "0", "text": "The user loves Python", "event": "UPDATE", "old_memory": "User likes Python"}]}),
]
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
memory = MemoryClass(MemoryConfig())
memory.add("I love Python now", user_id="test_user", infer=True)
# embed should be called exactly twice:
# 1. For the extracted fact "User loves Python now" (search)
# 2. For the rewritten text "The user loves Python" (pre-cached before _update_memory)
# It should NOT be called a 3rd time inside _update_memory
assert embedder.embed.call_count == 2
mock_vector_store.update.assert_called_once()
+458
View File
@@ -0,0 +1,458 @@
"""Tests for the vector store default path fix (issue #4279).
Verifies that vector store defaults use platform-appropriate persistent directories
instead of /tmp, which is ephemeral and may be inaccessible in restricted environments.
"""
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from mem0.vector_stores.configs import (
VectorStoreConfig,
_migrate_legacy_data,
get_default_vector_store_path,
)
# ---------------------------------------------------------------------------
# get_default_vector_store_path unit tests
# ---------------------------------------------------------------------------
class TestGetDefaultVectorStorePath:
"""Tests for the get_default_vector_store_path helper function."""
def _clean_env(self, monkeypatch):
"""Remove all relevant env vars to get a clean baseline."""
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
def test_default_falls_back_to_local_share(self, monkeypatch):
"""With no env vars set, path should be ~/.local/share/mem0/{provider}."""
self._clean_env(monkeypatch)
result = get_default_vector_store_path("qdrant")
expected = str(Path.home() / ".local" / "share" / "mem0" / "qdrant")
assert result == expected
def test_xdg_data_home_respected(self, monkeypatch):
"""XDG_DATA_HOME should be used as the base when set."""
self._clean_env(monkeypatch)
monkeypatch.setenv("XDG_DATA_HOME", "/custom/xdg/data")
result = get_default_vector_store_path("chroma")
assert result == "/custom/xdg/data/mem0/chroma"
def test_appdata_respected_on_windows(self, monkeypatch):
"""APPDATA should be used when XDG_DATA_HOME is not set (Windows)."""
self._clean_env(monkeypatch)
monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
result = get_default_vector_store_path("faiss")
expected = str(Path("C:\\Users\\test\\AppData\\Roaming") / "mem0" / "faiss")
assert result == expected
def test_xdg_takes_precedence_over_appdata(self, monkeypatch):
"""XDG_DATA_HOME should take precedence over APPDATA."""
self._clean_env(monkeypatch)
monkeypatch.setenv("XDG_DATA_HOME", "/xdg/path")
monkeypatch.setenv("APPDATA", "C:\\windows\\path")
result = get_default_vector_store_path("qdrant")
assert result == "/xdg/path/mem0/qdrant"
def test_mem0_data_dir_takes_highest_precedence(self, monkeypatch):
"""MEM0_DATA_DIR should override all other env vars."""
self._clean_env(monkeypatch)
monkeypatch.setenv("MEM0_DATA_DIR", "/custom/mem0/data")
monkeypatch.setenv("XDG_DATA_HOME", "/should/be/ignored")
monkeypatch.setenv("APPDATA", "C:\\also\\ignored")
result = get_default_vector_store_path("qdrant")
assert result == "/custom/mem0/data/qdrant"
def test_mem0_data_dir_no_extra_mem0_subdir(self, monkeypatch):
"""MEM0_DATA_DIR is already mem0-specific, so no extra 'mem0' subdir."""
self._clean_env(monkeypatch)
monkeypatch.setenv("MEM0_DATA_DIR", "/data/mem0")
result = get_default_vector_store_path("qdrant")
# Should be /data/mem0/qdrant, NOT /data/mem0/mem0/qdrant
assert result == "/data/mem0/qdrant"
def test_different_providers_get_different_paths(self, monkeypatch):
"""Each provider should get its own subdirectory."""
self._clean_env(monkeypatch)
qdrant_path = get_default_vector_store_path("qdrant")
chroma_path = get_default_vector_store_path("chroma")
faiss_path = get_default_vector_store_path("faiss")
assert qdrant_path != chroma_path != faiss_path
assert qdrant_path.endswith("/qdrant")
assert chroma_path.endswith("/chroma")
assert faiss_path.endswith("/faiss")
def test_path_never_contains_tmp(self, monkeypatch):
"""Default path should never be under /tmp (the whole point of #4279)."""
self._clean_env(monkeypatch)
for provider in ("qdrant", "chroma", "faiss"):
result = get_default_vector_store_path(provider)
assert not result.startswith("/tmp"), f"Provider {provider} still defaults to /tmp: {result}"
# ---------------------------------------------------------------------------
# VectorStoreConfig integration tests
# ---------------------------------------------------------------------------
class TestVectorStoreConfigPathInjection:
"""Tests that VectorStoreConfig correctly injects the new default path."""
def _clean_env(self, monkeypatch):
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
def test_qdrant_gets_persistent_default_path(self, monkeypatch):
"""Qdrant should get a persistent default path, not /tmp."""
self._clean_env(monkeypatch)
vc = VectorStoreConfig(provider="qdrant", config={})
assert "/tmp" not in vc.config.path
assert "mem0" in vc.config.path
assert vc.config.path.endswith("/qdrant")
def test_explicit_path_not_overridden(self, monkeypatch):
"""An explicitly provided path should be used as-is."""
self._clean_env(monkeypatch)
custom_path = "/my/custom/qdrant/path"
vc = VectorStoreConfig(provider="qdrant", config={"path": custom_path})
assert vc.config.path == custom_path
def test_explicit_tmp_path_still_works(self, monkeypatch):
"""Users who explicitly want /tmp should still be able to use it."""
self._clean_env(monkeypatch)
vc = VectorStoreConfig(provider="qdrant", config={"path": "/tmp/qdrant"})
assert vc.config.path == "/tmp/qdrant"
def test_mem0_data_dir_flows_through_config(self, monkeypatch):
"""MEM0_DATA_DIR should be respected through VectorStoreConfig."""
self._clean_env(monkeypatch)
monkeypatch.setenv("MEM0_DATA_DIR", "/custom/data")
vc = VectorStoreConfig(provider="qdrant", config={})
assert vc.config.path == "/custom/data/qdrant"
def test_xdg_data_home_flows_through_config(self, monkeypatch):
"""XDG_DATA_HOME should be respected through VectorStoreConfig."""
self._clean_env(monkeypatch)
monkeypatch.setenv("XDG_DATA_HOME", "/xdg/data")
vc = VectorStoreConfig(provider="qdrant", config={})
assert vc.config.path == "/xdg/data/mem0/qdrant"
# ---------------------------------------------------------------------------
# Qdrant-specific config tests
# ---------------------------------------------------------------------------
class TestQdrantConfigDefault:
"""Tests that QdrantConfig's own default_factory uses the new path."""
def _clean_env(self, monkeypatch):
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
def test_qdrant_field_default_is_persistent(self, monkeypatch):
"""QdrantConfig's path Field default should not be /tmp."""
self._clean_env(monkeypatch)
from mem0.configs.vector_stores.qdrant import QdrantConfig
# When path is explicitly provided, the before-validator sees it
config = QdrantConfig(path=get_default_vector_store_path("qdrant"))
assert "/tmp" not in config.path
assert config.path.endswith("/qdrant")
def test_qdrant_via_vector_store_config_no_path(self, monkeypatch):
"""Creating QdrantConfig via VectorStoreConfig with empty config should work."""
self._clean_env(monkeypatch)
vc = VectorStoreConfig(provider="qdrant", config={})
assert vc.config.path is not None
assert "/tmp" not in vc.config.path
# ---------------------------------------------------------------------------
# ChromaDB-specific config tests
# ---------------------------------------------------------------------------
class TestChromaDbConfigDefault:
"""Tests that ChromaDB's sentinel check works with the new default path."""
def _clean_env(self, monkeypatch):
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
def test_chroma_default_path_via_vector_store_config(self, monkeypatch):
"""ChromaDB via VectorStoreConfig should get a non-/tmp default path."""
self._clean_env(monkeypatch)
vc = VectorStoreConfig(provider="chroma", config={})
assert "/tmp" not in vc.config.path
assert vc.config.path.endswith("/chroma")
def test_chroma_cloud_config_strips_default_path(self, monkeypatch):
"""ChromaDB cloud config should strip the injected default path."""
self._clean_env(monkeypatch)
default_path = get_default_vector_store_path("chroma")
from mem0.configs.vector_stores.chroma import ChromaDbConfig
# Simulate what VectorStoreConfig does: inject default path, then create config
config = ChromaDbConfig(
path=default_path,
api_key="test-key",
tenant="test-tenant",
)
# When cloud config is provided and path matches default, path should be stripped
assert config.path is None
def test_chroma_explicit_path_with_cloud_raises(self, monkeypatch):
"""ChromaDB with both explicit custom path and cloud config should raise."""
self._clean_env(monkeypatch)
from mem0.configs.vector_stores.chroma import ChromaDbConfig
with pytest.raises(ValueError, match="Cannot specify both"):
ChromaDbConfig(
path="/my/custom/path",
api_key="test-key",
tenant="test-tenant",
)
# ---------------------------------------------------------------------------
# FAISS-specific tests
# ---------------------------------------------------------------------------
class TestFaissDefaultPath:
"""Tests that FAISS uses the new default path."""
def _clean_env(self, monkeypatch):
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
def test_faiss_default_path_via_vector_store_config(self, monkeypatch):
"""FAISS via VectorStoreConfig should get a non-/tmp default path."""
self._clean_env(monkeypatch)
vc = VectorStoreConfig(provider="faiss", config={})
assert "/tmp" not in vc.config.path
assert vc.config.path.endswith("/faiss")
def test_faiss_implementation_uses_new_default(self, monkeypatch):
"""FAISS implementation's own fallback should not use /tmp."""
self._clean_env(monkeypatch)
with patch("faiss.IndexFlatL2") as mock_index:
mock_index.return_value = MagicMock()
with patch("faiss.write_index"):
from mem0.vector_stores.faiss import FAISS
store = FAISS(collection_name="test")
assert "/tmp" not in store.path
assert "mem0" in store.path
# ---------------------------------------------------------------------------
# Legacy path migration warning tests
# ---------------------------------------------------------------------------
class TestLegacyPathMigration:
"""Tests that legacy /tmp data is automatically migrated to the new path.
Uses the ``legacy_path`` parameter of ``_migrate_legacy_data`` so that every
test exercises real filesystem operations (via pytest ``tmp_path``) instead of
mocking ``Path`` and ``shutil.copytree``. This catches real issues like
permission errors, path resolution, and symlink handling that mocks silently
skip over.
"""
def test_auto_migration_copies_legacy_data(self, tmp_path, caplog):
"""When legacy dir has data and new path doesn't exist, data is copied."""
legacy = tmp_path / "legacy_qdrant"
legacy.mkdir()
(legacy / "collection").mkdir()
(legacy / "collection" / "data.bin").write_bytes(b"\x00real-vector-data")
new = tmp_path / "new_qdrant"
with caplog.at_level(logging.INFO, logger="mem0.vector_stores.configs"):
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
# Data was actually copied
assert (new / "collection" / "data.bin").exists()
assert (new / "collection" / "data.bin").read_bytes() == b"\x00real-vector-data"
# Sentinel was written
assert (new / ".migration_complete").exists()
# Legacy data is untouched (copy, not move)
assert (legacy / "collection" / "data.bin").exists()
assert "migrated" in caplog.text.lower()
def test_no_migration_when_legacy_path_missing(self, tmp_path):
"""No-op when the legacy directory doesn't exist at all."""
nonexistent = tmp_path / "does_not_exist"
new = tmp_path / "new"
_migrate_legacy_data("qdrant", str(new), legacy_path=str(nonexistent))
assert not new.exists()
def test_no_migration_when_legacy_path_empty_dir(self, tmp_path):
"""No-op when the legacy directory exists but is empty."""
legacy = tmp_path / "empty_qdrant"
legacy.mkdir()
new = tmp_path / "new"
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
assert not new.exists()
def test_no_migration_when_legacy_path_is_symlink(self, tmp_path):
"""Refuse to migrate if legacy path is a symlink (prevents symlink attacks)."""
real_dir = tmp_path / "real_data"
real_dir.mkdir()
(real_dir / "secret.key").write_text("sensitive")
symlink = tmp_path / "symlink_qdrant"
symlink.symlink_to(real_dir)
new = tmp_path / "new"
_migrate_legacy_data("qdrant", str(new), legacy_path=str(symlink))
assert not new.exists()
def test_no_migration_when_new_path_already_has_data(self, tmp_path, caplog):
"""Don't clobber existing data at the new path."""
legacy = tmp_path / "legacy"
legacy.mkdir()
(legacy / "old.bin").write_text("legacy data")
new = tmp_path / "new"
new.mkdir()
(new / "existing.bin").write_text("keep this")
with caplog.at_level(logging.INFO, logger="mem0.vector_stores.configs"):
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
# Existing data preserved, legacy data NOT copied
assert (new / "existing.bin").read_text() == "keep this"
assert not (new / "old.bin").exists()
assert "both legacy path" in caplog.text.lower()
def test_no_migration_when_sentinel_exists(self, tmp_path):
"""Skip migration when .migration_complete sentinel already exists."""
legacy = tmp_path / "legacy"
legacy.mkdir()
(legacy / "data.bin").write_text("legacy data")
new = tmp_path / "new"
new.mkdir()
(new / ".migration_complete").touch()
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
# Legacy data was NOT copied (sentinel prevented it)
assert not (new / "data.bin").exists()
def test_migration_failure_falls_back_to_warning(self, tmp_path, caplog):
"""If copytree fails, log a warning with manual instructions."""
legacy = tmp_path / "legacy"
legacy.mkdir()
(legacy / "data.bin").write_text("legacy data")
new = tmp_path / "new"
with patch("shutil.copytree", side_effect=PermissionError("denied")):
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.configs"):
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
# No sentinel on failure
assert not (new / ".migration_complete").exists()
assert "denied" in caplog.text
def test_migration_is_idempotent(self, tmp_path):
"""Running migration twice doesn't duplicate data (sentinel prevents re-run)."""
legacy = tmp_path / "legacy"
legacy.mkdir()
(legacy / "data.bin").write_text("original")
new = tmp_path / "new"
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
assert (new / "data.bin").read_text() == "original"
# Modify legacy data after first migration
(legacy / "data.bin").write_text("modified")
# Second call is a no-op because sentinel exists
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
assert (new / "data.bin").read_text() == "original"
def test_migration_preserves_nested_directory_structure(self, tmp_path):
"""Deep directory trees are faithfully copied."""
legacy = tmp_path / "legacy"
(legacy / "collections" / "default" / "segments").mkdir(parents=True)
(legacy / "collections" / "default" / "segments" / "index.bin").write_bytes(b"\x01\x02")
(legacy / "meta.json").write_text('{"version": 1}')
new = tmp_path / "new"
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
assert (new / "meta.json").read_text() == '{"version": 1}'
assert (new / "collections" / "default" / "segments" / "index.bin").read_bytes() == b"\x01\x02"
def test_no_migration_when_explicit_path_provided(self, monkeypatch, caplog):
"""VectorStoreConfig with explicit path should not trigger migration."""
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.configs"):
VectorStoreConfig(provider="qdrant", config={"path": "/my/path"})
assert "migrat" not in caplog.text.lower()
# ---------------------------------------------------------------------------
# Cross-platform / issue #4279 scenario tests
# ---------------------------------------------------------------------------
class TestIssue4279Scenarios:
"""End-to-end tests for the specific scenarios described in issue #4279."""
def _clean_env(self, monkeypatch):
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
monkeypatch.delenv(var, raising=False)
def test_windows_no_tmp(self, monkeypatch):
"""Windows doesn't have /tmp — APPDATA should be used."""
self._clean_env(monkeypatch)
monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
for provider in ("qdrant", "chroma", "faiss"):
path = get_default_vector_store_path(provider)
assert not path.startswith("/tmp"), f"Windows path for {provider} still uses /tmp"
assert "AppData" in path
def test_linux_systemd_with_xdg(self, monkeypatch):
"""Linux systemd services should respect XDG_DATA_HOME."""
self._clean_env(monkeypatch)
monkeypatch.setenv("XDG_DATA_HOME", "/var/lib/myservice")
path = get_default_vector_store_path("qdrant")
assert path == "/var/lib/myservice/mem0/qdrant"
def test_docker_with_custom_data_dir(self, monkeypatch):
"""Docker users can mount a volume and point MEM0_DATA_DIR to it."""
self._clean_env(monkeypatch)
monkeypatch.setenv("MEM0_DATA_DIR", "/data/mem0")
path = get_default_vector_store_path("qdrant")
assert path == "/data/mem0/qdrant"
def test_macos_default_is_persistent(self, monkeypatch):
"""macOS default should be under home directory, not /tmp."""
self._clean_env(monkeypatch)
path = get_default_vector_store_path("qdrant")
assert path.startswith(str(Path.home()))
assert "/tmp" not in path
def test_all_affected_providers_fixed(self, monkeypatch):
"""All providers with a path field (qdrant, chroma, faiss) should be fixed."""
self._clean_env(monkeypatch)
for provider in ("qdrant", "chroma", "faiss"):
vc = VectorStoreConfig(provider=provider, config={})
assert "/tmp" not in vc.config.path, (
f"Provider '{provider}' still defaults to /tmp: {vc.config.path}"
)
+45 -1
View File
@@ -203,6 +203,10 @@ def test_delete(mongo_vector_fixture):
def test_update(mongo_vector_fixture):
"""
Test that update() uses dot notation for payload fields instead of replacing
the entire payload document.
"""
mongo_vector, mock_collection, _ = mongo_vector_fixture
vector_id = "id1"
updated_vector = [0.3] * 1536
@@ -212,11 +216,51 @@ def test_update(mongo_vector_fixture):
mongo_vector.update(vector_id=vector_id, vector=updated_vector, payload=updated_payload)
# Should use dot notation (payload.name) instead of full replacement (payload)
mock_collection.update_one.assert_called_once_with(
{"_id": vector_id}, {"$set": {"embedding": updated_vector, "payload": updated_payload}}
{"_id": vector_id}, {"$set": {"embedding": updated_vector, "payload.name": "updated_vector"}}
)
def test_update_payload_only_uses_dot_notation(mongo_vector_fixture):
"""
Test that updating only the payload uses dot notation for each field,
preserving existing metadata fields not included in the update.
"""
mongo_vector, mock_collection, _ = mongo_vector_fixture
mock_collection.update_one.return_value = MagicMock(matched_count=1)
mongo_vector.update(
vector_id="id1",
payload={"data": "updated text", "hash": "def456", "updated_at": "2025-06-01"},
)
set_arg = mock_collection.update_one.call_args[0][1]["$set"]
# Only the specified fields should be in $set, using dot notation
assert set_arg == {
"payload.data": "updated text",
"payload.hash": "def456",
"payload.updated_at": "2025-06-01",
}
# "payload" key itself should NOT appear (that would replace the whole document)
assert "payload" not in set_arg
def test_update_vector_only_does_not_touch_payload(mongo_vector_fixture):
"""Test that updating only the vector does not touch any payload fields."""
mongo_vector, mock_collection, _ = mongo_vector_fixture
mock_collection.update_one.return_value = MagicMock(matched_count=1)
mongo_vector.update(vector_id="id1", vector=[0.7, 0.8, 0.9])
set_arg = mock_collection.update_one.call_args[0][1]["$set"]
assert set_arg == {"embedding": [0.7, 0.8, 0.9]}
assert not any(k.startswith("payload.") for k in set_arg)
def test_get(mongo_vector_fixture):
mongo_vector, mock_collection, _ = mongo_vector_fixture
vector_id = "id1"