From 57c83b00726186abe4c29274dcd43e923163839b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:48:02 +0000 Subject: [PATCH] fix: invalidate config cache on file mtime change for MCP stdio server The module-level _CONFIG_CACHE was never refreshed in long-lived processes (e.g. MCP stdio server used by Claude Desktop), so bm project set-cloud or set-local had no effect until the server was restarted. Add a companion _CONFIG_CACHE_MTIME variable that records the config file's mtime when the cache is populated. load_config() now does a cheap stat() call on each invocation and drops the cache when the mtime has changed, allowing the updated routing mode to take effect immediately without a server restart. save_config() clears both cache variables so in-process saves still work correctly. Fixes #660 Co-authored-by: Drew Cain Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- src/basic_memory/config.py | 36 ++++++++-- tests/test_config.py | 139 +++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 006c9df7..01f249b2 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -629,6 +629,10 @@ class BasicMemoryConfig(BaseSettings): # Module-level cache for configuration _CONFIG_CACHE: Optional[BasicMemoryConfig] = None +# mtime of the config file when the cache was last populated. +# Used to detect out-of-process changes (e.g. CLI set-cloud/set-local while +# the MCP stdio server is running) so the long-lived process picks up updates. +_CONFIG_CACHE_MTIME: Optional[float] = None class ConfigManager: @@ -663,10 +667,29 @@ class ConfigManager: following Pydantic Settings best practices. Uses module-level cache for performance across ConfigManager instances. + The cache is validated against the config file's mtime on every call so + that long-lived processes (MCP stdio server) automatically pick up + changes made by external commands like `bm project set-cloud`. """ - global _CONFIG_CACHE + global _CONFIG_CACHE, _CONFIG_CACHE_MTIME - # Return cached config if available + # --- Detect out-of-process config changes via mtime --- + # Trigger: another process (CLI set-cloud/set-local) wrote a new config + # while this process was running and has a cached copy. + # Why: _CONFIG_CACHE is never invalidated across process boundaries; + # a cheap stat() call lets us detect the change without polling. + # Outcome: stale cache is dropped and the file is re-read below. + if _CONFIG_CACHE is not None and self.config_file.exists(): + current_mtime = self.config_file.stat().st_mtime + if current_mtime != _CONFIG_CACHE_MTIME: + logger.debug( + f"Config file modified since last load (mtime changed), " + f"invalidating cache: {self.config_file}" + ) + _CONFIG_CACHE = None + _CONFIG_CACHE_MTIME = None + + # Return cached config if still valid if _CONFIG_CACHE is not None: return _CONFIG_CACHE @@ -722,6 +745,8 @@ class ConfigManager: merged_data[field_name] = env_dict[field_name] _CONFIG_CACHE = BasicMemoryConfig(**merged_data) + # Record mtime so we can detect future out-of-process changes. + _CONFIG_CACHE_MTIME = self.config_file.stat().st_mtime # Re-save to normalize legacy config into current format if needs_resave: @@ -753,10 +778,13 @@ class ConfigManager: def save_config(self, config: BasicMemoryConfig) -> None: """Save configuration to file and invalidate cache.""" - global _CONFIG_CACHE + global _CONFIG_CACHE, _CONFIG_CACHE_MTIME save_basic_memory_config(self.config_file, config) - # Invalidate cache so next load_config() reads fresh data + # Invalidate cache so next load_config() reads fresh data. + # Also reset mtime so the in-process re-read after save picks up the + # new file rather than seeing the newly-written mtime as "unchanged". _CONFIG_CACHE = None + _CONFIG_CACHE_MTIME = None @property def projects(self) -> Dict[str, str]: diff --git a/tests/test_config.py b/tests/test_config.py index a8d6488c..8e871619 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1237,3 +1237,142 @@ class TestLocalSyncPathMigration: assert result["projects"]["local-proj"]["path"] == local_path assert result["projects"]["cloud-only"]["path"] == "cloud-only" assert result["projects"]["cloud-bisync"]["path"] == bisync_path + + +class TestConfigCacheMtimeInvalidation: + """Test that the config cache is invalidated when the file changes on disk. + + This covers the MCP stdio server scenario where a long-lived process must + pick up `bm project set-cloud` / `set-local` changes made by a separate + CLI process without requiring a server restart. + """ + + @pytest.fixture(autouse=True) + def reset_cache(self): + """Ensure the module-level cache is clean before and after each test.""" + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + basic_memory.config._CONFIG_CACHE_MTIME = None + yield + basic_memory.config._CONFIG_CACHE = None + basic_memory.config._CONFIG_CACHE_MTIME = None + + def _make_manager(self, tmp_path): + """Create a ConfigManager pointing at a temp directory.""" + manager = ConfigManager() + manager.config_dir = tmp_path / ".basic-memory" + manager.config_file = manager.config_dir / "config.json" + manager.config_dir.mkdir(parents=True, exist_ok=True) + return manager + + def test_cache_is_populated_after_first_load(self, tmp_path, monkeypatch): + """First load should populate the cache and record the file mtime.""" + import basic_memory.config + + monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) + manager = self._make_manager(tmp_path) + project_path = tmp_path / "main" + + import json + + manager.config_file.write_text( + json.dumps({"projects": {"main": {"path": str(project_path)}}}) + ) + + manager.load_config() + + assert basic_memory.config._CONFIG_CACHE is not None + assert basic_memory.config._CONFIG_CACHE_MTIME == manager.config_file.stat().st_mtime + + def test_unchanged_file_returns_same_cached_object(self, tmp_path, monkeypatch): + """Repeated loads with no file change should return the identical cached object.""" + import json + + monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) + manager = self._make_manager(tmp_path) + project_path = tmp_path / "main" + + manager.config_file.write_text( + json.dumps({"projects": {"main": {"path": str(project_path)}}}) + ) + + first = manager.load_config() + second = manager.load_config() + + assert first is second + + def test_modified_file_invalidates_cache(self, tmp_path, monkeypatch): + """When the config file mtime changes, load_config() must reload from disk. + + This is the core scenario: a CLI command (set-cloud / set-local) runs in + a separate process, writes a new config.json, and the long-lived MCP + stdio server should detect the mtime change and return updated routing. + """ + import json + import os + import basic_memory.config + + monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) + manager = self._make_manager(tmp_path) + project_path = tmp_path / "main" + + # --- Initial state: project is LOCAL --- + manager.config_file.write_text( + json.dumps( + { + "projects": { + "main": {"path": str(project_path), "mode": "local"}, + } + } + ) + ) + first = manager.load_config() + assert first.get_project_mode("main") == ProjectMode.LOCAL + + # --- Simulate external process writing updated config (set-cloud) --- + # Advance mtime by 1 second so the stat() check detects the change even + # on filesystems with 1-second mtime resolution. + manager.config_file.write_text( + json.dumps( + { + "projects": { + "main": {"path": str(project_path), "mode": "cloud"}, + } + } + ) + ) + current_mtime = manager.config_file.stat().st_mtime + os.utime(manager.config_file, (current_mtime + 1.0, current_mtime + 1.0)) + + # Re-load — must detect the mtime change and reload from disk + second = manager.load_config() + + assert second is not first + assert second.get_project_mode("main") == ProjectMode.CLOUD + assert basic_memory.config._CONFIG_CACHE_MTIME == manager.config_file.stat().st_mtime + + def test_save_config_resets_mtime_cache(self, tmp_path, monkeypatch): + """save_config() must clear both _CONFIG_CACHE and _CONFIG_CACHE_MTIME.""" + import json + import basic_memory.config + + monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) + manager = self._make_manager(tmp_path) + project_path = tmp_path / "main" + + manager.config_file.write_text( + json.dumps({"projects": {"main": {"path": str(project_path)}}}) + ) + manager.load_config() + + # Both should be populated after a load + assert basic_memory.config._CONFIG_CACHE is not None + assert basic_memory.config._CONFIG_CACHE_MTIME is not None + + # save_config() should clear both + config = manager.load_config() + manager.save_config(config) + + assert basic_memory.config._CONFIG_CACHE is None + assert basic_memory.config._CONFIG_CACHE_MTIME is None