From dccfc72dd168a67338526071785e77263dc79736 Mon Sep 17 00:00:00 2001 From: Vaibhav Seth Date: Sat, 4 Jul 2026 19:48:09 +0200 Subject: [PATCH 1/2] Revert "fix: resolve mem0 mcp lint issues" This reverts commit 9e6205f9a9bb163d78b1dbd517f96bc9b7664064. --- server/mcp/mem0_mcp_bridge/server.py | 2 +- tests/test_mem0_mcp_bridge.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/mcp/mem0_mcp_bridge/server.py b/server/mcp/mem0_mcp_bridge/server.py index 4ff83cdee2..6e067d7381 100644 --- a/server/mcp/mem0_mcp_bridge/server.py +++ b/server/mcp/mem0_mcp_bridge/server.py @@ -9,7 +9,7 @@ from mcp.server.transport_security import TransportSecuritySettings from pydantic import Field -from .client import Mem0SelfHostedClient, app_id_to_agent_id, default_user_filter +from .client import Mem0SelfHostedClient, app_id_to_agent_id, app_filters_to_agent_filters, default_user_filter logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s | %(message)s") logger = logging.getLogger("mem0_mcp_bridge") diff --git a/tests/test_mem0_mcp_bridge.py b/tests/test_mem0_mcp_bridge.py index 6f758ee6eb..2ecbf5bf89 100644 --- a/tests/test_mem0_mcp_bridge.py +++ b/tests/test_mem0_mcp_bridge.py @@ -8,7 +8,7 @@ if MCP_DIR not in sys.path: sys.path.insert(0, MCP_DIR) -from mem0_mcp_bridge.client import app_id_to_agent_id, default_user_filter # noqa: E402 +from mem0_mcp_bridge.client import app_id_to_agent_id, default_user_filter def test_bridge_maps_app_id_to_agent_id(): From ee7fe98227d1481aa660d83180b8ba04ff8f4531 Mon Sep 17 00:00:00 2001 From: Vaibhav Seth Date: Sat, 4 Jul 2026 19:48:09 +0200 Subject: [PATCH 2/2] Revert "feat: add self-hosted mem0 MCP bridge" This reverts commit 85dc7863fdb840e2311c083e81068baf4915396a. --- .github/workflows/sync-upstream.yml | 80 -------- integrations/mem0-plugin/scripts/_api.py | 120 ------------ integrations/mem0-plugin/scripts/_search.py | 17 +- .../mem0-plugin/scripts/auto_capture.py | 28 ++- .../mem0-plugin/scripts/auto_import.py | 94 +++++---- .../scripts/capture_compact_summary.py | 25 ++- .../scripts/capture_session_summary.py | 25 ++- .../scripts/import_competing_tools.py | 17 +- .../mem0-plugin/scripts/on_pre_compact.py | 26 ++- .../mem0-plugin/scripts/on_session_start.sh | 22 ++- .../mem0-plugin/scripts/session_timeline.py | 27 ++- .../mem0-plugin/tests/test_self_hosted_api.py | 59 ------ server/mcp/Dockerfile | 14 -- server/mcp/README.md | 43 ----- server/mcp/mem0_mcp_bridge/__init__.py | 1 - server/mcp/mem0_mcp_bridge/client.py | 79 -------- server/mcp/mem0_mcp_bridge/server.py | 180 ------------------ server/mcp/requirements.txt | 3 - tests/test_mem0_mcp_bridge.py | 32 ---- 19 files changed, 197 insertions(+), 695 deletions(-) delete mode 100644 .github/workflows/sync-upstream.yml delete mode 100644 integrations/mem0-plugin/scripts/_api.py delete mode 100644 integrations/mem0-plugin/tests/test_self_hosted_api.py delete mode 100644 server/mcp/Dockerfile delete mode 100644 server/mcp/README.md delete mode 100644 server/mcp/mem0_mcp_bridge/__init__.py delete mode 100644 server/mcp/mem0_mcp_bridge/client.py delete mode 100644 server/mcp/mem0_mcp_bridge/server.py delete mode 100644 server/mcp/requirements.txt delete mode 100644 tests/test_mem0_mcp_bridge.py diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml deleted file mode 100644 index 71aca772b0..0000000000 --- a/.github/workflows/sync-upstream.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Sync Upstream - -on: - workflow_dispatch: - schedule: - - cron: "17 * * * *" - -permissions: - contents: write - pull-requests: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - name: Checkout fork branch - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Add upstream remote - run: | - git remote add upstream https://github.com/mem0ai/mem0.git - git fetch upstream main - - - name: Check for upstream changes - id: changes - run: | - if [ "$(git rev-list --count origin/main..upstream/main)" -eq 0 ]; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Stop when upstream is unchanged - if: steps.changes.outputs.changed != 'true' - run: echo "Upstream main is already in sync." - - - name: Create sync branch - if: steps.changes.outputs.changed == 'true' - run: | - git checkout -B sync/upstream origin/main - - - name: Merge upstream into sync branch - if: steps.changes.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git merge --no-edit upstream/main - - - name: Push sync branch - if: steps.changes.outputs.changed == 'true' - run: | - git push --force-with-lease origin sync/upstream - - - name: Create or update PR - if: steps.changes.outputs.changed == 'true' - id: pr - env: - GH_TOKEN: ${{ github.token }} - run: | - PR_NUMBER="$(gh pr list --head sync/upstream --base main --state open --json number --jq '.[0].number')" - if [ -n "$PR_NUMBER" ]; then - echo "PR already exists: #$PR_NUMBER" - else - gh pr create \ - --base main \ - --head sync/upstream \ - --title "chore: sync upstream mem0" \ - --body "Automated sync from mem0ai/mem0 main into this fork." - PR_NUMBER="$(gh pr list --head sync/upstream --base main --state open --json number --jq '.[0].number')" - fi - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - - - name: Enable auto-merge - if: steps.changes.outputs.changed == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh pr merge "${{ steps.pr.outputs.pr_number }}" --auto --squash diff --git a/integrations/mem0-plugin/scripts/_api.py b/integrations/mem0-plugin/scripts/_api.py deleted file mode 100644 index a8373fa132..0000000000 --- a/integrations/mem0-plugin/scripts/_api.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Mem0 REST compatibility helpers for plugin hooks. - -Cloud remains the default. Set ``MEM0_API_MODE=self_hosted`` and -``MEM0_API_URL`` to target the self-hosted FastAPI server. -""" - -from __future__ import annotations - -import json -import os -import urllib.parse -import urllib.request -from typing import Any - -CLOUD_API_URL = "https://api.mem0.ai" - - -def api_mode() -> str: - raw = os.environ.get("MEM0_API_MODE", "cloud").strip().lower().replace("-", "_") - return "self_hosted" if raw in {"self_hosted", "selfhosted", "self"} else "cloud" - - -def api_base_url() -> str: - return os.environ.get("MEM0_API_URL", CLOUD_API_URL).rstrip("/") - - -def auth_headers(api_key: str) -> dict[str, str]: - if api_mode() == "self_hosted": - header = os.environ.get("MEM0_AUTH_HEADER", "X-API-Key") - return {header: api_key} - return {"Authorization": f"Token {api_key}"} - - -def _request_json( - api_key: str, - path: str, - *, - method: str = "GET", - body: dict[str, Any] | None = None, - timeout: int = 15, -) -> tuple[int, Any]: - data = json.dumps(body).encode("utf-8") if body is not None else None - headers = {"Content-Type": "application/json", **auth_headers(api_key)} - req = urllib.request.Request(f"{api_base_url()}{path}", data=data, headers=headers, method=method) - with urllib.request.urlopen(req, timeout=timeout) as resp: - raw = resp.read() - if not raw: - return resp.status, {} - if not isinstance(raw, (bytes, bytearray, str)): - return resp.status, {} - return resp.status, json.loads(raw) - - -def _self_hosted_body(body: dict[str, Any]) -> dict[str, Any]: - mapped = dict(body) - app_id = mapped.pop("app_id", None) - if app_id and not mapped.get("agent_id"): - mapped["agent_id"] = app_id - return mapped - - -def _self_hosted_filters(value: Any) -> Any: - if isinstance(value, list): - return [_self_hosted_filters(item) for item in value] - if not isinstance(value, dict): - return value - mapped: dict[str, Any] = {} - for key, item in value.items(): - mapped["agent_id" if key == "app_id" else key] = _self_hosted_filters(item) - return mapped - - -def add_memory(api_key: str, body: dict[str, Any], timeout: int = 15) -> tuple[int, Any]: - path = "/memories" if api_mode() == "self_hosted" else "/v3/memories/add/" - if api_mode() == "self_hosted": - body = _self_hosted_body(body) - return _request_json(api_key, path, method="POST", body=body, timeout=timeout) - - -def search_memories(api_key: str, body: dict[str, Any], timeout: int = 5) -> tuple[int, Any]: - path = "/search" if api_mode() == "self_hosted" else "/v3/memories/search/" - if api_mode() == "self_hosted" and "limit" in body and "top_k" not in body: - body = {**body, "top_k": body["limit"]} - if api_mode() == "self_hosted" and "filters" in body: - body = {**body, "filters": _self_hosted_filters(body["filters"])} - return _request_json(api_key, path, method="POST", body=body, timeout=timeout) - - -def list_memories(api_key: str, body: dict[str, Any], timeout: int = 5) -> tuple[int, Any]: - if api_mode() == "self_hosted": - filters = _self_hosted_filters(body.get("filters") or {}) - params: dict[str, Any] = {} - for item in filters.get("AND", []): - if not isinstance(item, dict): - continue - for key in ("user_id", "agent_id", "run_id"): - if key in item and isinstance(item[key], str) and item[key] != "*": - params[key] = item[key] - if "page_size" in body: - params["top_k"] = body["page_size"] - elif "top_k" in body: - params["top_k"] = body["top_k"] - query = f"?{urllib.parse.urlencode(params)}" if params else "" - return _request_json(api_key, f"/memories{query}", method="GET", timeout=timeout) - - page = body.get("page", 1) - page_size = body.get("page_size") or body.get("top_k") or 10 - return _request_json( - api_key, - f"/v3/memories/?page={page}&page_size={page_size}", - method="POST", - body={"filters": body.get("filters", {})}, - timeout=timeout, - ) - - -def delete_memory(api_key: str, memory_id: str, timeout: int = 10) -> tuple[int, Any]: - if api_mode() == "self_hosted": - return _request_json(api_key, f"/memories/{memory_id}", method="DELETE", timeout=timeout) - return _request_json(api_key, f"/v1/memories/{memory_id}/", method="DELETE", timeout=timeout) diff --git a/integrations/mem0-plugin/scripts/_search.py b/integrations/mem0-plugin/scripts/_search.py index 668302239a..5a19cfd41f 100644 --- a/integrations/mem0-plugin/scripts/_search.py +++ b/integrations/mem0-plugin/scripts/_search.py @@ -6,10 +6,11 @@ from __future__ import annotations +import json import os +import urllib.request -from _api import search_memories as api_search_memories - +SEARCH_URL = "https://api.mem0.ai/v3/memories/search/" SEARCH_TIMEOUT = 5 @@ -32,8 +33,16 @@ def should_rerank() -> bool: def _do_search(api_key: str, payload: dict) -> list[dict]: - _status, data = api_search_memories(api_key, payload, timeout=SEARCH_TIMEOUT) - return data if isinstance(data, list) else data.get("results", []) + body = json.dumps(payload).encode() + req = urllib.request.Request( + SEARCH_URL, + data=body, + headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r: + data = json.loads(r.read()) + return data if isinstance(data, list) else data.get("results", []) def search_memories( diff --git a/integrations/mem0-plugin/scripts/auto_capture.py b/integrations/mem0-plugin/scripts/auto_capture.py index 255c43869b..356c02803b 100755 --- a/integrations/mem0-plugin/scripts/auto_capture.py +++ b/integrations/mem0-plugin/scripts/auto_capture.py @@ -17,9 +17,9 @@ import os import sys import urllib.error +import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import add_memory from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id @@ -39,6 +39,7 @@ except OSError: pass +API_URL = "https://api.mem0.ai" TAIL_LINES = 200 MAX_CONTENT_CHARS = 8000 MIN_CONTENT_CHARS = 100 @@ -126,14 +127,25 @@ def store_exchange(api_key: str, messages: list[dict], user_id: str, "infer": True, } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) try: - status, result = add_memory(api_key, body, timeout=15) - if status in (200, 201): - log.info("Auto-captured: event_id=%s status=%s", - result.get("event_id", "?"), result.get("status", "?")) - return True - log.warning("API returned status %d", status) - return False + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + result = json.loads(resp.read()) + log.info("Auto-captured: event_id=%s status=%s", + result.get("event_id", "?"), result.get("status", "?")) + return True + log.warning("API returned status %d", resp.status) + return False except urllib.error.URLError as e: log.warning("API call failed: %s", e) return False diff --git a/integrations/mem0-plugin/scripts/auto_import.py b/integrations/mem0-plugin/scripts/auto_import.py index 5c52f371b9..004772bbd7 100644 --- a/integrations/mem0-plugin/scripts/auto_import.py +++ b/integrations/mem0-plugin/scripts/auto_import.py @@ -18,9 +18,9 @@ import os import sys import urllib.error +import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import add_memory, delete_memory, search_memories from _chunking import filter_and_truncate, split_by_headers from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id, save_project_mapping @@ -41,6 +41,7 @@ except OSError: pass +API_URL = "https://api.mem0.ai" MAX_FILE_SIZE = 100_000 # skip files over 100 KB TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"] HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json") @@ -121,7 +122,7 @@ def save_hashes(hashes: dict[str, str]) -> None: def already_imported(api_key: str, user_id: str, project_id: str, filename: str) -> bool: - body = { + body = json.dumps({ "query": filename, "filters": { "AND": [ @@ -132,23 +133,30 @@ def already_imported(api_key: str, user_id: str, project_id: str, filename: str) }, "top_k": 10, "threshold": 0.0, - } + }).encode() + req = urllib.request.Request( + f"{API_URL}/v3/memories/search/", + data=body, + headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"}, + method="POST", + ) try: - _status, data = search_memories(api_key, body, timeout=5) - results = data if isinstance(data, list) else data.get("results", []) - for result in results: - meta = result.get("metadata", {}) if isinstance(result, dict) else {} - file_field = meta.get("file", "") - if file_field == filename or file_field.startswith(f"{filename}["): - return True - return False + with urllib.request.urlopen(req, timeout=5) as r: + data = json.loads(r.read()) + results = data if isinstance(data, list) else data.get("results", []) + for result in results: + meta = result.get("metadata", {}) if isinstance(result, dict) else {} + file_field = meta.get("file", "") + if file_field == filename or file_field.startswith(f"{filename}["): + return True + return False except Exception: return False def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: str) -> int: """Find and delete existing chunks for a file before re-import. Returns count deleted.""" - body = { + body = json.dumps({ "query": filename, "filters": { "AND": [ @@ -159,20 +167,27 @@ def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: }, "top_k": 20, "threshold": 0.0, - } + }).encode() + req = urllib.request.Request( + f"{API_URL}/v3/memories/search/", + data=body, + headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"}, + method="POST", + ) ids_to_delete = [] try: - _status, data = search_memories(api_key, body, timeout=10) - results = data if isinstance(data, list) else data.get("results", []) - for result in results: - if not isinstance(result, dict): - continue - meta = result.get("metadata", {}) - file_field = meta.get("file", "") - if file_field == filename or file_field.startswith(f"{filename}["): - mid = result.get("id") - if mid: - ids_to_delete.append(mid) + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read()) + results = data if isinstance(data, list) else data.get("results", []) + for result in results: + if not isinstance(result, dict): + continue + meta = result.get("metadata", {}) + file_field = meta.get("file", "") + if file_field == filename or file_field.startswith(f"{filename}["): + mid = result.get("id") + if mid: + ids_to_delete.append(mid) except Exception as e: log.warning("Failed to search for stale chunks of %s: %s", filename, e) return 0 @@ -180,8 +195,12 @@ def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: deleted = 0 for mid in ids_to_delete: try: - status, _data = delete_memory(api_key, mid, timeout=10) - if status in (200, 204): + del_req = urllib.request.Request( + f"{API_URL}/v1/memories/{mid}/", + headers={"Authorization": f"Token {api_key}"}, + method="DELETE", + ) + with urllib.request.urlopen(del_req, timeout=10): deleted += 1 except Exception as e: log.warning("Failed to delete stale chunk %s: %s", mid, e) @@ -213,13 +232,24 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project "infer": False, } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) + try: - status, _result = add_memory(api_key, body, timeout=15) - if status in (200, 201): - log.info("Imported %s (project=%s)", filename, project_id) - return True - log.warning("API returned status %d for %s", status, filename) - return False + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Imported %s (project=%s)", filename, project_id) + return True + log.warning("API returned status %d for %s", resp.status, filename) + return False except urllib.error.URLError as e: log.warning("API call failed for %s: %s", filename, e) return False diff --git a/integrations/mem0-plugin/scripts/capture_compact_summary.py b/integrations/mem0-plugin/scripts/capture_compact_summary.py index 5ce7c2ad2b..d6e70b3567 100644 --- a/integrations/mem0-plugin/scripts/capture_compact_summary.py +++ b/integrations/mem0-plugin/scripts/capture_compact_summary.py @@ -21,10 +21,10 @@ import os import sys import urllib.error +import urllib.request from datetime import date, timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import add_memory from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id @@ -44,6 +44,7 @@ except OSError: pass +API_URL = "https://api.mem0.ai" MAX_TAIL_LINES = 2000 MAX_SUMMARY_CHARS = 50000 # Compact summaries describe a single session's state -- stale after a quarter. @@ -112,13 +113,23 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro "expiration_date": expires, } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) try: - status, _result = add_memory(api_key, body, timeout=15) - if status in (200, 201): - log.info("Compact summary stored") - return True - log.warning("API returned status %d", status) - return False + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Compact summary stored") + return True + log.warning("API returned status %d", resp.status) + return False except urllib.error.URLError as e: log.warning("API call failed: %s", e) return False diff --git a/integrations/mem0-plugin/scripts/capture_session_summary.py b/integrations/mem0-plugin/scripts/capture_session_summary.py index dfd1d373a1..441ed9a6e7 100644 --- a/integrations/mem0-plugin/scripts/capture_session_summary.py +++ b/integrations/mem0-plugin/scripts/capture_session_summary.py @@ -19,10 +19,10 @@ import re import sys import urllib.error +import urllib.request from datetime import date, timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import add_memory from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id @@ -42,6 +42,7 @@ except OSError: pass +API_URL = "https://api.mem0.ai" MAX_TAIL_LINES = 3000 MAX_SUMMARY_CHARS = 50000 SUMMARY_EXPIRY_DAYS = 90 @@ -184,13 +185,23 @@ def store_summary( "expiration_date": expires, } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) try: - status, _result = add_memory(api_key, body, timeout=15) - if status in (200, 201): - log.info("Session summary stored") - return True - log.warning("API returned status %d", status) - return False + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Session summary stored") + return True + log.warning("API returned status %d", resp.status) + return False except urllib.error.URLError as e: log.warning("API call failed: %s", e) return False diff --git a/integrations/mem0-plugin/scripts/import_competing_tools.py b/integrations/mem0-plugin/scripts/import_competing_tools.py index e5538ed147..968bbbcd22 100644 --- a/integrations/mem0-plugin/scripts/import_competing_tools.py +++ b/integrations/mem0-plugin/scripts/import_competing_tools.py @@ -22,9 +22,9 @@ import os import sys import urllib.error +import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import add_memory from _chunking import ( filter_and_truncate, split_by_headers, @@ -33,6 +33,7 @@ from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id +API_URL = "https://api.mem0.ai" HASH_STORE = os.path.expanduser("~/.mem0/import_hashes.json") @@ -80,9 +81,19 @@ def post_memory(api_key: str, content: str, user_id: str, project_id: str, branc "metadata": metadata, "infer": False, } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) try: - status, _result = add_memory(api_key, body, timeout=20) - return status in (200, 201) + with urllib.request.urlopen(req, timeout=20) as resp: + return resp.status in (200, 201) except urllib.error.URLError as e: print(f" [warn] API call failed: {e}", file=sys.stderr) return False diff --git a/integrations/mem0-plugin/scripts/on_pre_compact.py b/integrations/mem0-plugin/scripts/on_pre_compact.py index e7b78b3ad6..882e264811 100755 --- a/integrations/mem0-plugin/scripts/on_pre_compact.py +++ b/integrations/mem0-plugin/scripts/on_pre_compact.py @@ -19,10 +19,10 @@ import os import sys import urllib.error +import urllib.request from datetime import date, timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import add_memory from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id @@ -42,6 +42,7 @@ except OSError: pass +API_URL = "https://api.mem0.ai" MAX_TAIL_LINES = 500 MAX_USER_MESSAGES = 30 MAX_BASH_COMMANDS = 20 @@ -178,13 +179,24 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_ "infer": True, } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) + try: - status, _result = add_memory(api_key, body, timeout=15) - if status in (200, 201): - log.info("Session state stored successfully") - return True - log.warning("API returned status %d", status) - return False + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Session state stored successfully") + return True + log.warning("API returned status %d", resp.status) + return False except urllib.error.URLError as e: log.warning("API call failed: %s", e) return False diff --git a/integrations/mem0-plugin/scripts/on_session_start.sh b/integrations/mem0-plugin/scripts/on_session_start.sh index 18cc278c33..9f89e6745e 100755 --- a/integrations/mem0-plugin/scripts/on_session_start.sh +++ b/integrations/mem0-plugin/scripts/on_session_start.sh @@ -71,22 +71,26 @@ fi MEM0_COUNT="?" if command -v python3 >/dev/null 2>&1; then - MEM0_COUNT=$(PYTHONPATH="$SCRIPT_DIR" python3 -c " + MEM0_COUNT=$(python3 -c " import json, os, urllib.request, urllib.error -from _api import list_memories api_key = os.environ.get('MEM0_API_KEY', '') user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default') app_id = os.environ.get('MEM0_PROJECT_ID', '') global_search = os.environ.get('MEM0_GLOBAL_SEARCH', 'false') == 'true' def get_count(filters): - _status, data = list_memories(api_key, {'filters': filters, 'page_size': 1}, timeout=5) - if isinstance(data, dict) and 'count' in data: - return data['count'] - if isinstance(data, dict) and 'results' in data: - return len(data['results']) - if isinstance(data, list): - return len(data) + body = json.dumps({'filters': filters}).encode() + req = urllib.request.Request( + 'https://api.mem0.ai/v3/memories/?page=1&page_size=1', + headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, + data=body, method='POST', + ) + with urllib.request.urlopen(req, timeout=5) as r: + data = json.loads(r.read()) + if isinstance(data, dict) and 'count' in data: + return data['count'] + if isinstance(data, list): + return len(data) return 0 try: diff --git a/integrations/mem0-plugin/scripts/session_timeline.py b/integrations/mem0-plugin/scripts/session_timeline.py index 03b4fc7a5c..86e22f7fbf 100644 --- a/integrations/mem0-plugin/scripts/session_timeline.py +++ b/integrations/mem0-plugin/scripts/session_timeline.py @@ -11,15 +11,17 @@ from __future__ import annotations +import json import os import sys +import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _api import list_memories from _formatting import TYPE_ICONS, format_age from _identity import resolve_api_key, resolve_user_id from _project import resolve_project_id +API_URL = "https://api.mem0.ai" MAX_RECENT = 10 MAX_SUMMARIES = 3 FETCH_TIMEOUT = 5 @@ -34,13 +36,24 @@ def fetch_recent_memories(api_key: str, user_id: str, project_id: str) -> list[d else: filters = {"AND": [{"user_id": user_id}, {"app_id": project_id}]} + body = json.dumps({"filters": filters}).encode() + req = urllib.request.Request( + f"{API_URL}/v3/memories/?page=1&page_size={MAX_RECENT}", + data=body, + headers={ + "Authorization": f"Token {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) try: - _status, result = list_memories(api_key, {"filters": filters, "page_size": MAX_RECENT}, timeout=FETCH_TIMEOUT) - if isinstance(result, dict) and "results" in result: - return result["results"][:MAX_RECENT] - if isinstance(result, list): - return result[:MAX_RECENT] - return [] + with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as r: + result = json.loads(r.read()) + if isinstance(result, dict) and "results" in result: + return result["results"][:MAX_RECENT] + if isinstance(result, list): + return result[:MAX_RECENT] + return [] except Exception: return [] diff --git a/integrations/mem0-plugin/tests/test_self_hosted_api.py b/integrations/mem0-plugin/tests/test_self_hosted_api.py deleted file mode 100644 index 3a17f1daec..0000000000 --- a/integrations/mem0-plugin/tests/test_self_hosted_api.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import json -from unittest.mock import MagicMock, patch - - -def test_self_hosted_add_memory_maps_app_id_to_agent_id(monkeypatch): - from _api import add_memory - - monkeypatch.setenv("MEM0_API_MODE", "self_hosted") - monkeypatch.setenv("MEM0_API_URL", "https://mem0.example.test") - - captured = {} - - def mock_urlopen(req, timeout=None): - captured["url"] = req.full_url - captured["headers"] = dict(req.header_items()) - captured["body"] = json.loads(req.data.decode("utf-8")) - resp = MagicMock() - resp.status = 200 - resp.read.return_value = b'{"results":[]}' - resp.__enter__ = lambda s: s - resp.__exit__ = MagicMock(return_value=False) - return resp - - with patch("urllib.request.urlopen", side_effect=mock_urlopen): - add_memory("m0sk-test", {"messages": [], "user_id": "demo-user", "app_id": "mem0"}) - - assert captured["url"] == "https://mem0.example.test/memories" - assert captured["headers"]["X-api-key"] == "m0sk-test" - assert captured["body"]["agent_id"] == "mem0" - assert "app_id" not in captured["body"] - - -def test_self_hosted_search_maps_app_id_filter(monkeypatch): - from _api import search_memories - - monkeypatch.setenv("MEM0_API_MODE", "self_hosted") - monkeypatch.setenv("MEM0_API_URL", "https://mem0.example.test") - - captured = {} - - def mock_urlopen(req, timeout=None): - captured["body"] = json.loads(req.data.decode("utf-8")) - resp = MagicMock() - resp.status = 200 - resp.read.return_value = b'{"results":[]}' - resp.__enter__ = lambda s: s - resp.__exit__ = MagicMock(return_value=False) - return resp - - with patch("urllib.request.urlopen", side_effect=mock_urlopen): - search_memories( - "m0sk-test", - {"query": "decisions", "filters": {"AND": [{"user_id": "demo-user"}, {"app_id": "mem0"}]}}, - ) - - assert {"agent_id": "mem0"} in captured["body"]["filters"]["AND"] - assert {"app_id": "mem0"} not in captured["body"]["filters"]["AND"] diff --git a/server/mcp/Dockerfile b/server/mcp/Dockerfile deleted file mode 100644 index e8824dedb8..0000000000 --- a/server/mcp/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM python:3.12-slim - -ENV PYTHONUNBUFFERED=1 -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY mem0_mcp_bridge ./mem0_mcp_bridge - -ENV HOST=0.0.0.0 -ENV PORT=8765 - -CMD ["python", "-m", "mem0_mcp_bridge.server"] diff --git a/server/mcp/README.md b/server/mcp/README.md deleted file mode 100644 index f9f6a034e0..0000000000 --- a/server/mcp/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Mem0 Self-Hosted MCP Bridge - -This sidecar exposes MCP tools for the self-hosted Mem0 REST server. It keeps the -hosted MCP tool names while forwarding requests to the self-hosted REST API. - -## Tools - -- `add_memory` -- `search_memories` -- `get_memories` -- `get_memory` -- `update_memory` -- `delete_memory` -- `delete_all_memories` -- `list_entities` -- `delete_entities` - -## Run With Docker Compose - -From the repository root: - -```bash -MEM0_API_KEY="m0sk_..." \ -MEM0_DEFAULT_USER_ID="demo-user" \ -docker compose -f docker-compose.mem0-mcp.yaml up -d --build -``` - -By default the sidecar joins the `mem0_mem0_network` Docker network and calls -`http://mem0:8000`. Override these when your deployment uses different names: - -```bash -MEM0_DOCKER_NETWORK="my_mem0_network" \ -MEM0_API_URL="http://mem0:8000" \ -docker compose -f docker-compose.mem0-mcp.yaml up -d --build -``` - -Expose `127.0.0.1:8765` through your reverse proxy or Tailscale HTTPS endpoint, -then configure MCP clients with the `/mcp` Streamable HTTP URL. - -## Scoping - -The self-hosted REST server supports `user_id`, `agent_id`, and `run_id`. The -bridge accepts hosted-compatible `app_id` and maps it to `agent_id`. diff --git a/server/mcp/mem0_mcp_bridge/__init__.py b/server/mcp/mem0_mcp_bridge/__init__.py deleted file mode 100644 index 2c05762b5e..0000000000 --- a/server/mcp/mem0_mcp_bridge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""MCP bridge for the Mem0 self-hosted REST server.""" diff --git a/server/mcp/mem0_mcp_bridge/client.py b/server/mcp/mem0_mcp_bridge/client.py deleted file mode 100644 index 49c2a35c8c..0000000000 --- a/server/mcp/mem0_mcp_bridge/client.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import os -from typing import Any - -import httpx - - -class Mem0SelfHostedClient: - def __init__(self, api_url: str, api_key: str, timeout: float = 15.0) -> None: - self.api_url = api_url.rstrip("/") - self.api_key = api_key - self.timeout = timeout - - @classmethod - def from_env(cls) -> "Mem0SelfHostedClient": - api_url = os.environ.get("MEM0_API_URL", "http://mem0:8000") - api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("ADMIN_API_KEY", "") - return cls(api_url=api_url, api_key=api_key) - - @property - def headers(self) -> dict[str, str]: - headers = {"Content-Type": "application/json"} - if self.api_key: - headers["X-API-Key"] = self.api_key - return headers - - def request( - self, - method: str, - path: str, - *, - json_body: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - ) -> Any: - with httpx.Client(timeout=self.timeout) as client: - response = client.request( - method, - f"{self.api_url}{path}", - headers=self.headers, - json=json_body, - params={k: v for k, v in (params or {}).items() if v is not None}, - ) - response.raise_for_status() - if not response.content: - return {} - return response.json() - - -def app_id_to_agent_id(payload: dict[str, Any]) -> dict[str, Any]: - mapped = dict(payload) - app_id = mapped.pop("app_id", None) - if app_id and not mapped.get("agent_id"): - mapped["agent_id"] = app_id - return mapped - - -def app_filters_to_agent_filters(value: Any) -> Any: - if isinstance(value, list): - return [app_filters_to_agent_filters(item) for item in value] - if not isinstance(value, dict): - return value - mapped: dict[str, Any] = {} - for key, item in value.items(): - mapped["agent_id" if key == "app_id" else key] = app_filters_to_agent_filters(item) - return mapped - - -def default_user_filter(default_user_id: str, filters: dict[str, Any] | None) -> dict[str, Any]: - if not filters: - return {"AND": [{"user_id": default_user_id}]} - text = str(filters) - if "user_id" in text or "agent_id" in text or "run_id" in text or "app_id" in text: - return app_filters_to_agent_filters(filters) - if not any(key in filters for key in ("AND", "OR", "NOT")): - filters = {"AND": [filters]} - filters = app_filters_to_agent_filters(filters) - filters.setdefault("AND", []).insert(0, {"user_id": default_user_id}) - return filters diff --git a/server/mcp/mem0_mcp_bridge/server.py b/server/mcp/mem0_mcp_bridge/server.py deleted file mode 100644 index 6e067d7381..0000000000 --- a/server/mcp/mem0_mcp_bridge/server.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -import json -import logging -import os -from typing import Annotated, Any - -from mcp.server.fastmcp import FastMCP -from mcp.server.transport_security import TransportSecuritySettings -from pydantic import Field - -from .client import Mem0SelfHostedClient, app_id_to_agent_id, app_filters_to_agent_filters, default_user_filter - -logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s | %(message)s") -logger = logging.getLogger("mem0_mcp_bridge") - -DEFAULT_USER_ID = os.environ.get("MEM0_DEFAULT_USER_ID", "mem0-mcp") -DEFAULT_AGENT_ID = os.environ.get("MEM0_DEFAULT_AGENT_ID") or os.environ.get("MEM0_DEFAULT_APP_ID", "") - - -def _json_call(func, *args, **kwargs) -> str: - try: - return json.dumps(func(*args, **kwargs), ensure_ascii=False) - except Exception as exc: - logger.exception("Mem0 MCP bridge call failed") - return json.dumps({"error": type(exc).__name__, "detail": str(exc)}, ensure_ascii=False) - - -def _client() -> Mem0SelfHostedClient: - return Mem0SelfHostedClient.from_env() - - -server = FastMCP( - "mem0", - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "8765")), - transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), -) - - -@server.tool(description="Store a new preference, fact, or conversation snippet.") -def add_memory( - text: Annotated[str | None, Field(default=None, description="Plain sentence summarizing what to store.")] = None, - messages: Annotated[list[dict[str, str]] | None, Field(default=None, description="Role/content messages.")] = None, - user_id: Annotated[str | None, Field(default=None, description="User scope.")] = None, - agent_id: Annotated[str | None, Field(default=None, description="Agent scope.")] = None, - app_id: Annotated[str | None, Field(default=None, description="Alias for agent_id in self-hosted mode.")] = None, - run_id: Annotated[str | None, Field(default=None, description="Run/session scope.")] = None, - metadata: Annotated[dict[str, Any] | None, Field(default=None, description="Metadata JSON.")] = None, - infer: Annotated[bool, Field(default=True, description="Whether Mem0 should extract facts.")] = True, -) -> str: - if not messages: - if not text: - return json.dumps({"error": "messages_missing", "detail": "Provide text or messages."}) - messages = [{"role": "user", "content": text}] - - payload = app_id_to_agent_id( - { - "messages": messages, - "user_id": user_id or DEFAULT_USER_ID, - "agent_id": agent_id or DEFAULT_AGENT_ID or None, - "app_id": app_id, - "run_id": run_id, - "metadata": metadata, - "infer": infer, - } - ) - payload = {k: v for k, v in payload.items() if v is not None} - return _json_call(_client().request, "POST", "/memories", json_body=payload) - - -@server.tool(description="Run a semantic search over existing memories.") -def search_memories( - query: Annotated[str, Field(description="Natural language search query.")], - filters: Annotated[dict[str, Any] | None, Field(default=None, description="Structured filters.")] = None, - limit: Annotated[int | None, Field(default=None, description="Maximum results.")] = None, - top_k: Annotated[int | None, Field(default=None, description="Maximum results alias.")] = None, - threshold: Annotated[float | None, Field(default=None, description="Minimum similarity score.")] = None, -) -> str: - payload: dict[str, Any] = { - "query": query, - "filters": default_user_filter(DEFAULT_USER_ID, filters), - "top_k": top_k or limit, - "threshold": threshold, - } - payload = {k: v for k, v in payload.items() if v is not None} - return _json_call(_client().request, "POST", "/search", json_body=payload) - - -@server.tool(description="List memories using structured filters.") -def get_memories( - filters: Annotated[dict[str, Any] | None, Field(default=None, description="Structured filters.")] = None, - page_size: Annotated[int | None, Field(default=None, description="Maximum memories to list.")] = None, - top_k: Annotated[int | None, Field(default=None, description="Maximum memories alias.")] = None, -) -> str: - scoped = default_user_filter(DEFAULT_USER_ID, filters) - params: dict[str, Any] = {"top_k": top_k or page_size} - for clause in scoped.get("AND", []): - if isinstance(clause, dict): - for key in ("user_id", "agent_id", "run_id"): - if key in clause and isinstance(clause[key], str) and clause[key] != "*": - params[key] = clause[key] - return _json_call(_client().request, "GET", "/memories", params=params) - - -@server.tool(description="Fetch a single memory by ID.") -def get_memory(memory_id: Annotated[str, Field(description="Memory ID.")]) -> str: - return _json_call(_client().request, "GET", f"/memories/{memory_id}") - - -@server.tool(description="Overwrite an existing memory's text.") -def update_memory( - memory_id: Annotated[str, Field(description="Memory ID.")], - text: Annotated[str, Field(description="Replacement memory text.")], - metadata: Annotated[dict[str, Any] | None, Field(default=None, description="Optional replacement metadata.")] = None, -) -> str: - payload = {"text": text} - if metadata is not None: - payload["metadata"] = metadata - return _json_call(_client().request, "PUT", f"/memories/{memory_id}", json_body=payload) - - -@server.tool(description="Delete one memory by ID.") -def delete_memory(memory_id: Annotated[str, Field(description="Memory ID.")]) -> str: - return _json_call(_client().request, "DELETE", f"/memories/{memory_id}") - - -@server.tool(description="Delete every memory in a user/agent/run scope.") -def delete_all_memories( - user_id: Annotated[str | None, Field(default=None, description="User scope.")] = None, - agent_id: Annotated[str | None, Field(default=None, description="Agent scope.")] = None, - app_id: Annotated[str | None, Field(default=None, description="Alias for agent_id.")] = None, - run_id: Annotated[str | None, Field(default=None, description="Run scope.")] = None, -) -> str: - params = app_id_to_agent_id( - { - "user_id": user_id or DEFAULT_USER_ID, - "agent_id": agent_id, - "app_id": app_id, - "run_id": run_id, - } - ) - return _json_call(_client().request, "DELETE", "/memories", params=params) - - -@server.tool(description="List users, agents, and runs currently holding memories.") -def list_entities() -> str: - return _json_call(_client().request, "GET", "/entities") - - -@server.tool(description="Delete a user/agent/run entity and its memories.") -def delete_entities( - user_id: Annotated[str | None, Field(default=None, description="User entity to delete.")] = None, - agent_id: Annotated[str | None, Field(default=None, description="Agent entity to delete.")] = None, - app_id: Annotated[str | None, Field(default=None, description="Alias for agent entity.")] = None, - run_id: Annotated[str | None, Field(default=None, description="Run entity to delete.")] = None, -) -> str: - mapped = app_id_to_agent_id({"user_id": user_id, "agent_id": agent_id, "app_id": app_id, "run_id": run_id}) - scopes = [(key[:-3], value) for key, value in mapped.items() if value and key.endswith("_id")] - if len(scopes) != 1: - return json.dumps({"error": "scope_invalid", "detail": "Provide exactly one user_id, agent_id/app_id, or run_id."}) - entity_type, entity_id = scopes[0] - return _json_call(_client().request, "DELETE", f"/entities/{entity_type}/{entity_id}") - - -@server.prompt() -def memory_assistant() -> str: - return ( - "Use Mem0 tools for long-term memory. Prefer scoped filters with user_id and app_id/agent_id. " - "Store durable facts, preferences, project decisions, and task learnings." - ) - - -def main() -> None: - logger.info("Starting Mem0 self-hosted MCP bridge at %s:%s", server.settings.host, server.settings.port) - server.run(transport="streamable-http") - - -if __name__ == "__main__": - main() diff --git a/server/mcp/requirements.txt b/server/mcp/requirements.txt deleted file mode 100644 index 1752a94307..0000000000 --- a/server/mcp/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -mcp[cli]>=1.25.4 -httpx>=0.28.0 -pydantic>=2.7.3 diff --git a/tests/test_mem0_mcp_bridge.py b/tests/test_mem0_mcp_bridge.py deleted file mode 100644 index 2ecbf5bf89..0000000000 --- a/tests/test_mem0_mcp_bridge.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import os -import sys - -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -MCP_DIR = os.path.join(ROOT, "server", "mcp") -if MCP_DIR not in sys.path: - sys.path.insert(0, MCP_DIR) - -from mem0_mcp_bridge.client import app_id_to_agent_id, default_user_filter - - -def test_bridge_maps_app_id_to_agent_id(): - payload = app_id_to_agent_id({"user_id": "demo-user", "app_id": "mem0", "messages": []}) - - assert payload["agent_id"] == "mem0" - assert "app_id" not in payload - - -def test_bridge_default_filter_maps_app_id(): - filters = default_user_filter("demo-user", {"AND": [{"app_id": "mem0"}]}) - - assert {"agent_id": "mem0"} in filters["AND"] - assert {"app_id": "mem0"} not in filters["AND"] - - -def test_bridge_default_filter_injects_user_id(): - filters = default_user_filter("demo-user", {"metadata": {"type": "decision"}}) - - assert filters["AND"][0] == {"user_id": "demo-user"} - assert {"metadata": {"type": "decision"}} in filters["AND"]