fix(plugins): give the MCP server the hooks' data dir, release 0.3.4

mcp_server.py never calls configure_harness, so it resolved ~/.mem0/mem0-plugin while the Cursor, Kimi and Antigravity hooks use ~/.mem0/<host>-plugin. A key set in Cursor's plugin settings and cached by the sessionStart hook was invisible to search_memories. build.py now writes DATA_DIR_NAME into each bundle's _harness_id.py and memory_core seeds its default from it, like the PLATFORM_* values.

An end-to-end test launches the real session-start hook and MCP server for Claude Code, Cursor and Codex the way each host does, with the key in plugin settings and in the mem0 init config, and checks the search reaches the API with that key.

Bumps the Python plugins to 0.3.4, OpenCode to 0.4.2, Pi and DeepSeek to 0.3.3, with changelog entries for each.

Fixes #7346
This commit is contained in:
kartik-mem0
2026-09-25 10:58:25 +05:30
parent 71dc0cae07
commit cbfb37c623
34 changed files with 362 additions and 101 deletions
@@ -103,6 +103,7 @@ def _render_harness_id(host: str, *, portable: bool = False) -> str:
"\n"
f'HARNESS_ID = "{host}"\n'
f'SOURCE_TAG = "{tag}"\n'
f'DATA_DIR_NAME = "{host}-plugin"\n'
"\n"
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
"# plugin family is one source; which editor it runs in is the application.\n"
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -1802,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
@@ -0,0 +1,150 @@
"""Launch each host's real hooks and MCP server the way the host does, and check the search is authenticated."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from string import Template
import pytest
INTEGRATIONS = Path(__file__).resolve().parents[2]
KEY = "m0-plugin-setting-key"
CODEX_INHERITED_ENV = ("HOME", "PATH", "LANG", "TMPDIR")
class _Mem0Api(BaseHTTPRequestHandler):
authorizations: list[str]
def do_POST(self) -> None:
self.rfile.read(int(self.headers.get("Content-Length") or 0))
self.authorizations.append(self.headers.get("Authorization", ""))
body = b'{"results": []}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
@pytest.fixture
def mem0_api():
handler = type("Handler", (_Mem0Api,), {"authorizations": []})
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", handler.authorizations
server.shutdown()
def _json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _expand(value: str, variables: dict[str, str]) -> str:
return Template(value).safe_substitute(variables)
def _run_hook(command: str, cwd: Path, env: dict[str, str], payload: dict) -> None:
result = subprocess.run(
["sh", "-c", command], cwd=cwd, env=env, input=json.dumps(payload), text=True, capture_output=True
)
assert result.returncode == 0, result.stderr
def _search(argv: list[str], cwd: Path, env: dict[str, str]) -> dict:
requests = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}},
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "search_memories", "arguments": {"query": "earlier fixes"}},
},
]
result = subprocess.run(
argv,
cwd=cwd,
env=env,
input="\n".join(json.dumps(request) for request in requests) + "\n",
text=True,
capture_output=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.splitlines()[-1])
def _claude_code(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
root = INTEGRATIONS / "claude-code-plugin"
plugin_env = {"CLAUDE_PLUGIN_ROOT": str(root), "CLAUDE_PLUGIN_DATA": str(tmp_path / "claude-data")}
hook_env = {**host_env, **plugin_env, **({"CLAUDE_PLUGIN_OPTION_API_KEY": KEY} if setting else {})}
command = _json(root / "hooks" / "hooks.json")["hooks"]["SessionStart"][0]["hooks"][0]["command"]
_run_hook(command, repo, hook_env, {"session_id": "s1", "cwd": str(repo)})
server = _json(root / ".mcp.json")["mcpServers"]["mem0"]
env = {**host_env, **{name: _expand(value, plugin_env) for name, value in server["env"].items()}}
return [sys.executable, *(_expand(argument, plugin_env) for argument in server["args"])], repo, env
def _cursor(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
root = INTEGRATIONS / "cursor-plugin"
command = _json(root / "hooks" / "hooks.json")["hooks"]["sessionStart"][0]["command"]
if setting:
command = command.replace("${api_key}", KEY)
hook_env = {**host_env, "CURSOR_PLUGIN_ROOT": str(root)}
_run_hook(command, repo, hook_env, {"conversation_id": "c1", "workspace_roots": [str(repo)]})
server = _json(root / "mcp.json")["mcpServers"]["mem0"]
args = [_expand(argument, {"CURSOR_PLUGIN_ROOT": str(root)}) for argument in server["args"]]
return [sys.executable, *args], repo, {**host_env, **server["env"]}
def _codex(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
root = INTEGRATIONS / "codex-plugin"
codex_env = {**host_env, **({"MEM0_API_KEY": KEY} if setting else {})}
data = str(tmp_path / "codex-data")
hook_env = {**codex_env, "PLUGIN_ROOT": str(root), "PLUGIN_DATA": data, "CLAUDE_PLUGIN_DATA": data}
command = _json(root / "hooks" / "hooks.json")["hooks"]["SessionStart"][0]["hooks"][0]["command"]
_run_hook(command, repo, hook_env, {"session_id": "s1", "cwd": str(repo)})
server = _json(root / ".mcp.json")["mcpServers"]["mem0"]
forwarded = [*CODEX_INHERITED_ENV, *server["env_vars"]]
env = {name: codex_env[name] for name in forwarded if name in codex_env}
return [sys.executable, *server["args"]], root / server["cwd"], env
HOSTS = {"claude-code": _claude_code, "cursor": _cursor, "codex": _codex}
@pytest.mark.parametrize("key_source", ["plugin setting", "mem0 init"])
@pytest.mark.parametrize("host", sorted(HOSTS))
def test_mcp_server_searches_with_the_key_the_user_configured(host, key_source, tmp_path, mem0_api):
"""The key reaches the MCP server however the host delivers it: plugin setting, env, or `mem0 init`."""
api_url, authorizations = mem0_api
home = tmp_path / "home"
repo = tmp_path / "repo"
repo.mkdir()
if key_source == "mem0 init":
(home / ".mem0").mkdir(parents=True)
(home / ".mem0" / "config.json").write_text(json.dumps({"platform": {"api_key": KEY}}), encoding="utf-8")
host_env = {
"HOME": str(home),
"PATH": os.environ["PATH"],
"MEM0_API_URL": api_url,
"MEM0_TELEMETRY": "false",
"MEM0_CODE_USER_ID": "test-user",
}
argv, cwd, env = HOSTS[host](host_env, tmp_path, repo, key_source == "plugin setting")
response = _search(argv, cwd, env)
assert not response["result"].get("isError"), response
assert authorizations == [f"Token {KEY}"]
@@ -83,6 +83,26 @@ def test_identity_resolves_without_init(harness, spec):
assert out == f"{harness} {source_tag}"
@pytest.mark.parametrize("harness,spec", sorted(HOSTS.items()))
def test_data_dir_resolves_without_init(harness, spec):
"""mcp_server never configures the harness, so its default must match configure_harness(harness) in hooks and skills."""
directory, _ = spec
core = _core_dir(directory)
if not core.exists():
pytest.skip(f"{directory} is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
out = _run(
core,
Path(tmp),
"import os; os.environ.pop('MEM0_CODE_DATA_DIR')\n"
"import mcp_server, memory_core; print(memory_core.data_dir())\n"
f"memory_core.configure_harness({harness!r}); print(memory_core.data_dir())",
)
expected = str(Path(tmp) / ".mem0" / f"{harness}-plugin")
assert out.splitlines() == [expected, expected]
def test_mcp_server_records_the_real_harness():
"""mcp_server imports telemetry and never initialises it (server.py has no init).