From 363c710c8a66bf12223aa489b83e74af081077d5 Mon Sep 17 00:00:00 2001 From: Ramon de Lima Ramos Date: Sun, 14 Jun 2026 16:35:06 -0300 Subject: [PATCH 1/2] feat(cli): disable claude auto-memory in mcp config --- README.md | 7 ++++++- docs/claude-code.md | 8 +++++++- src/synapto/cli.py | 21 +++++++++++++++++--- tests/unit/test_cli.py | 44 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 38d51b9..6170a87 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,17 @@ The recommended way is `uvx` with `--refresh` — every restart pulls the latest "mcpServers": { "synapto": { "command": "uvx", - "args": ["--refresh", "synapto", "serve"] + "args": ["--refresh", "synapto", "serve"], + "env": { + "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1" + } } } } ``` +Set `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` for Claude Code so Synapto remains the single memory sink instead of duplicating new memories into Claude's flat-file auto-memory. + **Cursor** (`.cursor/mcp.json`): ```json diff --git a/docs/claude-code.md b/docs/claude-code.md index d314b74..f155d13 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -19,7 +19,10 @@ synapto init "mcpServers": { "synapto": { "command": "uvx", - "args": ["--refresh", "synapto", "serve"] + "args": ["--refresh", "synapto", "serve"], + "env": { + "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1" + } } } } @@ -34,6 +37,7 @@ synapto init "command": "uvx", "args": ["--refresh", "synapto", "serve"], "env": { + "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", "SYNAPTO_DEFAULT_TENANT": "my-project" } } @@ -43,6 +47,8 @@ synapto init > **Why `--refresh`?** Without it, `uvx` reuses the cached environment across restarts, so a new Synapto release on PyPI will not be picked up until the cache expires or you run `uv cache clean synapto` manually. `--refresh` tells `uv` to re-resolve the package on every launch, adding 1–3 seconds to Claude Code's MCP startup in exchange for "always on the latest version" — the right default while Synapto is shipping fast. Drop the flag (or pin a version like `"synapto==0.2.0"`) once you want to freeze a known-good build. +> **Why `CLAUDE_CODE_DISABLE_AUTO_MEMORY`?** When Synapto is the active memory layer, this tells Claude Code to skip its flat-file auto-memory extraction so new memories do not get duplicated outside Synapto. + ### Upgrading mid-session If a new Synapto release lands while Claude Code is already running, the existing MCP subprocess keeps using the version it started with. To pick up the new release, **fully quit Claude Code (`Cmd+Q`) and relaunch** — the MCP server is a child process of Claude Code, so a window-close is not enough. With `--refresh` in place, the relaunch will pull the new version automatically. diff --git a/src/synapto/cli.py b/src/synapto/cli.py index 3f08b01..fe8c744 100644 --- a/src/synapto/cli.py +++ b/src/synapto/cli.py @@ -14,6 +14,7 @@ # Memory migration: how many texts to embed per provider call. EMBEDDING_BATCH_SIZE = 64 +CLAUDE_CODE_DISABLE_AUTO_MEMORY_ENV = "CLAUDE_CODE_DISABLE_AUTO_MEMORY" def _run(coro): @@ -558,7 +559,12 @@ def _detect_mcp_clients(home=None) -> list[dict]: return clients -def _write_mcp_config(config_path, tenant: str = "default") -> None: +def _write_mcp_config( + config_path, + tenant: str = "default", + *, + disable_claude_auto_memory: bool = False, +) -> None: """Write synapto MCP config using uvx for auto-updates.""" from pathlib import Path @@ -573,8 +579,13 @@ def _write_mcp_config(config_path, tenant: str = "default") -> None: "command": "uvx", "args": ["synapto", "serve"], } + env = {} if tenant != "default": - server_config["env"] = {"SYNAPTO_DEFAULT_TENANT": tenant} + env["SYNAPTO_DEFAULT_TENANT"] = tenant + if disable_claude_auto_memory: + env[CLAUDE_CODE_DISABLE_AUTO_MEMORY_ENV] = "1" + if env: + server_config["env"] = env servers["synapto"] = server_config existing["mcpServers"] = servers @@ -593,7 +604,11 @@ def _offer_mcp_config(tenant: str = "default") -> None: click.echo("\n--- mcp client configuration ---") for client in clients: if click.confirm(f"configure {client['name']} with auto-update (uvx)?", default=True): - _write_mcp_config(client["path"], tenant) + _write_mcp_config( + client["path"], + tenant, + disable_claude_auto_memory=client["name"] == "Claude Code", + ) click.echo(f" written: {client['path']}") else: click.echo(f" skipped: {client['name']}") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9712113..842bc95 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -6,7 +6,7 @@ from click.testing import CliRunner -from synapto.cli import _detect_mcp_clients, _write_mcp_config +from synapto.cli import _detect_mcp_clients, _offer_mcp_config, _write_mcp_config class TestDetectMcpClients: @@ -57,6 +57,20 @@ def test_creates_config_with_custom_tenant(self, tmp_path): data = json.loads(config_path.read_text()) assert data["mcpServers"]["synapto"]["env"]["SYNAPTO_DEFAULT_TENANT"] == "my-project" + def test_can_disable_claude_code_auto_memory(self, tmp_path): + config_path = tmp_path / "mcp.json" + + _write_mcp_config( + config_path, + tenant="my-project", + disable_claude_auto_memory=True, + ) + + data = json.loads(config_path.read_text()) + env = data["mcpServers"]["synapto"]["env"] + assert env["SYNAPTO_DEFAULT_TENANT"] == "my-project" + assert env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] == "1" + def test_preserves_existing_servers(self, tmp_path): config_path = tmp_path / "mcp.json" config_path.write_text(json.dumps({ @@ -94,6 +108,34 @@ def test_creates_parent_directories(self, tmp_path): assert data["mcpServers"]["synapto"]["command"] == "uvx" +class TestOfferMcpConfig: + def test_disables_claude_code_auto_memory_only_for_claude(self, tmp_path, monkeypatch): + claude_config = tmp_path / "claude.json" + cursor_config = tmp_path / "cursor.json" + + import synapto.cli as cli + + monkeypatch.setattr( + cli, + "_detect_mcp_clients", + lambda: [ + {"name": "Claude Code", "path": claude_config, "key": "mcpServers"}, + {"name": "Cursor", "path": cursor_config, "key": "mcpServers"}, + ], + ) + monkeypatch.setattr(cli.click, "confirm", lambda *args, **kwargs: True) + + _offer_mcp_config(tenant="default") + + claude_data = json.loads(claude_config.read_text()) + cursor_data = json.loads(cursor_config.read_text()) + assert ( + claude_data["mcpServers"]["synapto"]["env"]["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] + == "1" + ) + assert "env" not in cursor_data["mcpServers"]["synapto"] + + class TestServeCommand: def test_serve_disables_fastmcp_banner(self, monkeypatch): """FastMCP's Rich banner bypasses logging, so serve must suppress it.""" From e9ba867cbf73b226de32f614be08721b3c2c3dd3 Mon Sep 17 00:00:00 2001 From: Ramon de Lima Ramos Date: Sun, 14 Jun 2026 19:40:07 -0300 Subject: [PATCH 2/2] feat(cli): add mcp config upgrade command --- README.md | 8 ++++ docs/claude-code.md | 10 +++++ src/synapto/cli.py | 90 +++++++++++++++++++++++++++++++++------ tests/unit/test_cli.py | 97 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6170a87..da7e99f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,14 @@ The recommended way is `uvx` with `--refresh` — every restart pulls the latest Set `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` for Claude Code so Synapto remains the single memory sink instead of duplicating new memories into Claude's flat-file auto-memory. +Existing Claude Code users can upgrade their MCP config in place: + +```bash +uvx --refresh synapto configure-mcp --client claude-code --yes +``` + +Restart Claude Code after running the command so the MCP subprocess receives the new environment. + **Cursor** (`.cursor/mcp.json`): ```json diff --git a/docs/claude-code.md b/docs/claude-code.md index f155d13..ebaac6d 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -49,6 +49,16 @@ synapto init > **Why `CLAUDE_CODE_DISABLE_AUTO_MEMORY`?** When Synapto is the active memory layer, this tells Claude Code to skip its flat-file auto-memory extraction so new memories do not get duplicated outside Synapto. +### Upgrading an existing MCP config + +If you already use Synapto with Claude Code, upgrade your config once after installing this release: + +```bash +uvx --refresh synapto configure-mcp --client claude-code --yes +``` + +The command preserves your existing Synapto `command` and `args`, adds `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`, and keeps any existing `SYNAPTO_DEFAULT_TENANT` unless you pass a new `--tenant`. + ### Upgrading mid-session If a new Synapto release lands while Claude Code is already running, the existing MCP subprocess keeps using the version it started with. To pick up the new release, **fully quit Claude Code (`Cmd+Q`) and relaunch** — the MCP server is a child process of Claude Code, so a window-close is not enough. With `--refresh` in place, the relaunch will pull the new version automatically. diff --git a/src/synapto/cli.py b/src/synapto/cli.py index fe8c744..c32c42d 100644 --- a/src/synapto/cli.py +++ b/src/synapto/cli.py @@ -5,6 +5,7 @@ import asyncio import json import logging +from pathlib import Path import click @@ -15,6 +16,9 @@ # Memory migration: how many texts to embed per provider call. EMBEDDING_BATCH_SIZE = 64 CLAUDE_CODE_DISABLE_AUTO_MEMORY_ENV = "CLAUDE_CODE_DISABLE_AUTO_MEMORY" +MCP_CLIENT_ALL = "all" +MCP_CLIENT_CLAUDE_CODE = "claude-code" +MCP_CLIENT_CURSOR = "cursor" def _run(coro): @@ -22,6 +26,15 @@ def _run(coro): return asyncio.run(coro) +def _mcp_client_slug(client: dict) -> str: + name = client["name"].lower() + if "claude" in name: + return MCP_CLIENT_CLAUDE_CODE + if "cursor" in name: + return MCP_CLIENT_CURSOR + return name.replace(" ", "-") + + @click.group() @click.version_option(version=__version__, prog_name="synapto") @click.option("--verbose", "-v", is_flag=True, help="enable debug logging") @@ -145,6 +158,48 @@ def serve() -> None: mcp.run(show_banner=False) +@main.command(name="configure-mcp") +@click.option( + "--client", + type=click.Choice([MCP_CLIENT_ALL, MCP_CLIENT_CLAUDE_CODE, MCP_CLIENT_CURSOR], case_sensitive=False), + default=MCP_CLIENT_ALL, + show_default=True, + help="MCP client config to update", +) +@click.option("--tenant", default=None, help="set or update SYNAPTO_DEFAULT_TENANT") +@click.option("--yes", "-y", is_flag=True, help="update detected MCP configs without prompting") +@click.option("--home", default=None, type=click.Path(exists=True), hidden=True) +def configure_mcp(client: str, tenant: str | None, yes: bool, home: str | None) -> None: + """Configure detected MCP clients for Synapto.""" + clients = _detect_mcp_clients(home=Path(home) if home else None) + target = client.lower() + selected = [ + c for c in clients + if target == MCP_CLIENT_ALL or _mcp_client_slug(c) == target + ] + + if not selected: + click.echo("no matching MCP clients detected") + return + + click.echo("synapto MCP configuration\n") + for detected in selected: + slug = _mcp_client_slug(detected) + if not yes and not click.confirm(f"configure {detected['name']} for Synapto?", default=True): + click.echo(f" skipped: {detected['name']}") + continue + + _write_mcp_config( + detected["path"], + tenant=tenant, + disable_claude_auto_memory=slug == MCP_CLIENT_CLAUDE_CODE, + preserve_existing_synapto=True, + ) + click.echo(f" written: {detected['path']}") + + click.echo("\nrestart your MCP client so the updated environment is loaded") + + @main.command() @click.argument("query") @click.option("--tenant", "-t", default=None, help="tenant/project scope") @@ -540,8 +595,6 @@ async def _import(): def _detect_mcp_clients(home=None) -> list[dict]: """Detect installed MCP clients and their config paths.""" - from pathlib import Path - home = home or Path.home() clients = [] @@ -561,13 +614,12 @@ def _detect_mcp_clients(home=None) -> list[dict]: def _write_mcp_config( config_path, - tenant: str = "default", + tenant: str | None = "default", *, disable_claude_auto_memory: bool = False, + preserve_existing_synapto: bool = False, ) -> None: """Write synapto MCP config using uvx for auto-updates.""" - from pathlib import Path - path = Path(config_path) existing = {} if path.exists(): @@ -575,24 +627,38 @@ def _write_mcp_config( existing = json.loads(f.read()) servers = existing.get("mcpServers", {}) - server_config: dict = { - "command": "uvx", - "args": ["synapto", "serve"], - } - env = {} - if tenant != "default": + current_server = servers.get("synapto") + if preserve_existing_synapto and isinstance(current_server, dict): + server_config: dict = dict(current_server) + else: + server_config = { + "command": "uvx", + "args": ["--refresh", "synapto", "serve"], + } + + existing_env = server_config.get("env") + env = dict(existing_env) if isinstance(existing_env, dict) else {} + if tenant is not None and tenant != "default": env["SYNAPTO_DEFAULT_TENANT"] = tenant + elif tenant == "default": + env.pop("SYNAPTO_DEFAULT_TENANT", None) + if disable_claude_auto_memory: env[CLAUDE_CODE_DISABLE_AUTO_MEMORY_ENV] = "1" + else: + env.pop(CLAUDE_CODE_DISABLE_AUTO_MEMORY_ENV, None) + if env: server_config["env"] = env + else: + server_config.pop("env", None) servers["synapto"] = server_config existing["mcpServers"] = servers path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w") as f: - f.write(json.dumps(existing, indent=2)) + f.write(json.dumps(existing, indent=2) + "\n") def _offer_mcp_config(tenant: str = "default") -> None: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 842bc95..765200a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -6,7 +6,7 @@ from click.testing import CliRunner -from synapto.cli import _detect_mcp_clients, _offer_mcp_config, _write_mcp_config +from synapto.cli import _detect_mcp_clients, _offer_mcp_config, _write_mcp_config, main class TestDetectMcpClients: @@ -46,7 +46,7 @@ def test_creates_new_config(self, tmp_path): data = json.loads(config_path.read_text()) assert data["mcpServers"]["synapto"]["command"] == "uvx" - assert data["mcpServers"]["synapto"]["args"] == ["synapto", "serve"] + assert data["mcpServers"]["synapto"]["args"] == ["--refresh", "synapto", "serve"] assert "env" not in data["mcpServers"]["synapto"] def test_creates_config_with_custom_tenant(self, tmp_path): @@ -97,6 +97,55 @@ def test_overwrites_existing_synapto_config(self, tmp_path): data = json.loads(config_path.read_text()) assert data["mcpServers"]["synapto"]["command"] == "uvx" + assert data["mcpServers"]["synapto"]["args"] == ["--refresh", "synapto", "serve"] + + def test_preserves_existing_synapto_command_when_upgrading(self, tmp_path): + config_path = tmp_path / "mcp.json" + config_path.write_text(json.dumps({ + "mcpServers": { + "synapto": { + "command": "uv", + "args": ["--directory", "/repo/synapto", "run", "synapto", "serve"], + "env": {"SYNAPTO_DEFAULT_TENANT": "existing"}, + } + } + })) + + _write_mcp_config( + config_path, + tenant=None, + disable_claude_auto_memory=True, + preserve_existing_synapto=True, + ) + + data = json.loads(config_path.read_text()) + server = data["mcpServers"]["synapto"] + assert server["command"] == "uv" + assert server["args"] == ["--directory", "/repo/synapto", "run", "synapto", "serve"] + assert server["env"]["SYNAPTO_DEFAULT_TENANT"] == "existing" + assert server["env"]["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] == "1" + + def test_cursor_upgrade_removes_claude_only_env(self, tmp_path): + config_path = tmp_path / "mcp.json" + config_path.write_text(json.dumps({ + "mcpServers": { + "synapto": { + "command": "uvx", + "args": ["--refresh", "synapto", "serve"], + "env": {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"}, + } + } + })) + + _write_mcp_config( + config_path, + tenant=None, + disable_claude_auto_memory=False, + preserve_existing_synapto=True, + ) + + data = json.loads(config_path.read_text()) + assert "env" not in data["mcpServers"]["synapto"] def test_creates_parent_directories(self, tmp_path): config_path = tmp_path / "nested" / "dir" / "mcp.json" @@ -136,6 +185,50 @@ def test_disables_claude_code_auto_memory_only_for_claude(self, tmp_path, monkey assert "env" not in cursor_data["mcpServers"]["synapto"] +class TestConfigureMcpCommand: + def test_configure_mcp_upgrades_detected_claude_config(self, tmp_path): + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + config_path = claude_dir / ".mcp.json" + config_path.write_text(json.dumps({ + "mcpServers": { + "synapto": { + "command": "uv", + "args": ["--directory", "/repo/synapto", "run", "synapto", "serve"], + } + } + })) + + result = CliRunner().invoke( + main, + ["configure-mcp", "--home", str(tmp_path), "--client", "claude-code", "--tenant", "project-a", "--yes"], + ) + + assert result.exit_code == 0 + data = json.loads(config_path.read_text()) + server = data["mcpServers"]["synapto"] + assert server["command"] == "uv" + assert server["args"] == ["--directory", "/repo/synapto", "run", "synapto", "serve"] + assert server["env"]["SYNAPTO_DEFAULT_TENANT"] == "project-a" + assert server["env"]["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] == "1" + assert "restart your MCP client" in result.output + + def test_configure_mcp_updates_cursor_without_claude_env(self, tmp_path): + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + config_path = cursor_dir / "mcp.json" + + result = CliRunner().invoke( + main, + ["configure-mcp", "--home", str(tmp_path), "--client", "cursor", "--tenant", "project-a", "--yes"], + ) + + assert result.exit_code == 0 + data = json.loads(config_path.read_text()) + server = data["mcpServers"]["synapto"] + assert server["env"] == {"SYNAPTO_DEFAULT_TENANT": "project-a"} + + class TestServeCommand: def test_serve_disables_fastmcp_banner(self, monkeypatch): """FastMCP's Rich banner bypasses logging, so serve must suppress it."""