Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
406110460d | ||
|
|
29b9059cea | ||
|
|
a84a565422 | ||
|
|
0b397eaf06 | ||
|
|
051551edb7 | ||
|
|
7e3b727528 | ||
|
|
13c7f84eec | ||
|
|
924ac00c52 | ||
|
|
2a36960f4c | ||
|
|
2e0f91e70d | ||
|
|
d1b4b304c7 | ||
|
|
2868bfe749 | ||
|
|
5431badfd4 |
@@ -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,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
|
||||
|
||||
@@ -12,8 +12,6 @@ component:
|
||||
matcher: "Graph Memory"
|
||||
- label: "ollama"
|
||||
matcher: "Ollama"
|
||||
- label: "openmemory"
|
||||
matcher: "OpenMemory"
|
||||
- label: "openclaw"
|
||||
matcher: "OpenClaw"
|
||||
- label: "rest-api"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user