Skip to content

Commit 318f0e5

Browse files
fix: resolve project state inconsistency between MCP and CLI
Fixes issue #148 where MCP tools and CLI commands showed different project states, causing "Project not found" errors and edit operation failures. ### Root Cause - MCP server uses ProjectSession (in-memory state) - CLI commands directly access config files via ConfigManager - When CLI sets default project, MCP session wasn't reloaded ### Solution - Add `refresh_from_config()` method to ProjectSession - Call session refresh in ProjectService after default changes - Remove manual config reload from CLI command - Add comprehensive tests to reproduce and verify fix ### Changes - **ProjectSession**: New refresh_from_config() method to reload from config - **ProjectService**: Auto-refresh session after set_default_project() - **CLI**: Simplified project command, API handles session refresh - **Tests**: Added tests for consistency issues and fix verification Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
1 parent b8191d0 commit 318f0e5

6 files changed

Lines changed: 668 additions & 8 deletions

File tree

src/basic_memory/cli/commands/project.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,20 +120,16 @@ def set_default_project(
120120
try:
121121
project_name = generate_permalink(name)
122122

123-
response = asyncio.run(call_put(client, f"projects/{project_name}/default"))
123+
response = asyncio.run(call_put(client, f"/projects/{project_name}/default"))
124124
result = ProjectStatusResponse.model_validate(response.json())
125125

126126
console.print(f"[green]{result.message}[/green]")
127127
except Exception as e:
128128
console.print(f"[red]Error setting default project: {str(e)}[/red]")
129129
raise typer.Exit(1)
130130

131-
# Reload configuration to apply the change
132-
from importlib import reload
133-
from basic_memory import config as config_module
134-
135-
reload(config_module)
136-
131+
# The API call above should have updated both config and MCP session
132+
# No need for manual reload - the project service handles this automatically
137133
console.print("[green]Project activated for current session[/green]")
138134

139135

src/basic_memory/mcp/project_session.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Optional
99
from loguru import logger
1010

11-
from basic_memory.config import ProjectConfig, get_project_config
11+
from basic_memory.config import ProjectConfig, get_project_config, config_manager
1212

1313

1414
@dataclass
@@ -64,6 +64,21 @@ def reset_to_default(self) -> None: # pragma: no cover
6464
self.current_project = self.default_project # pragma: no cover
6565
logger.info(f"Reset project context to default: {self.default_project}") # pragma: no cover
6666

67+
def refresh_from_config(self) -> None:
68+
"""Refresh session state from current configuration.
69+
70+
This method reloads the default project from config and reinitializes
71+
the session. This should be called when the default project is changed
72+
via CLI or API to ensure MCP session stays in sync.
73+
"""
74+
# Reload config to get latest default project
75+
current_config = config_manager.load_config()
76+
new_default = current_config.default_project
77+
78+
# Reinitialize with new default
79+
self.initialize(new_default)
80+
logger.info(f"Refreshed project session from config, new default: {new_default}")
81+
6782

6883
# Global session instance
6984
session = ProjectSession()

src/basic_memory/services/project_service.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ async def set_default_project(self, name: str) -> None:
153153
logger.error(f"Project '{name}' exists in config but not in database")
154154

155155
logger.info(f"Project '{name}' set as default in configuration and database")
156+
157+
# Refresh MCP session to pick up the new default project
158+
try:
159+
from basic_memory.mcp.project_session import session
160+
session.refresh_from_config()
161+
except ImportError:
162+
# MCP components might not be available in all contexts (e.g., CLI-only usage)
163+
logger.debug("MCP session not available, skipping session refresh")
156164

157165
async def _ensure_single_default_project(self) -> None:
158166
"""Ensure only one project has is_default=True.
@@ -273,6 +281,14 @@ async def synchronize_projects(self) -> None: # pragma: no cover
273281
await self.repository.set_as_default(project.id)
274282

275283
logger.info("Project synchronization complete")
284+
285+
# Refresh MCP session to ensure it's in sync with current config
286+
try:
287+
from basic_memory.mcp.project_session import session
288+
session.refresh_from_config()
289+
except ImportError:
290+
# MCP components might not be available in all contexts
291+
logger.debug("MCP session not available, skipping session refresh")
276292

277293
async def update_project( # pragma: no cover
278294
self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""Test project state consistency between MCP and CLI components."""
2+
3+
import asyncio
4+
import os
5+
import tempfile
6+
from pathlib import Path
7+
from typing import Generator
8+
9+
import pytest
10+
11+
from basic_memory import config
12+
from basic_memory.config import ConfigManager, BasicMemoryConfig
13+
from basic_memory.mcp.project_session import ProjectSession, session, get_active_project
14+
from basic_memory.cli.commands.project import list_projects, set_default_project
15+
from basic_memory.services.project_service import ProjectService
16+
from basic_memory.repository.project_repository import ProjectRepository
17+
from basic_memory.models.project import Project
18+
19+
20+
@pytest.fixture
21+
def temp_config_dir() -> Generator[Path, None, None]:
22+
"""Create a temporary config directory for testing."""
23+
with tempfile.TemporaryDirectory() as temp_dir:
24+
temp_path = Path(temp_dir)
25+
yield temp_path
26+
27+
28+
@pytest.fixture
29+
def temp_projects(temp_config_dir: Path) -> dict[str, Path]:
30+
"""Create temporary project directories."""
31+
projects = {}
32+
for name in ["main", "minerva", "test-project"]:
33+
project_path = temp_config_dir / name
34+
project_path.mkdir(parents=True, exist_ok=True)
35+
projects[name] = project_path
36+
return projects
37+
38+
39+
@pytest.fixture
40+
def config_manager_with_projects(temp_config_dir: Path, temp_projects: dict[str, Path]) -> ConfigManager:
41+
"""Create a config manager with test projects."""
42+
# Mock the config directory
43+
original_config_dir = config.config_manager.config_dir
44+
original_config_file = config.config_manager.config_file
45+
46+
try:
47+
config.config_manager.config_dir = temp_config_dir
48+
config.config_manager.config_file = temp_config_dir / "config.json"
49+
50+
# Create test config
51+
test_config = BasicMemoryConfig(
52+
projects={name: str(path) for name, path in temp_projects.items()},
53+
default_project="main"
54+
)
55+
config.config_manager.config = test_config
56+
config.config_manager.save_config(test_config)
57+
58+
yield config.config_manager
59+
finally:
60+
# Restore original paths
61+
config.config_manager.config_dir = original_config_dir
62+
config.config_manager.config_file = original_config_file
63+
64+
65+
class TestProjectStateConsistency:
66+
"""Test cases for project state consistency between MCP and CLI."""
67+
68+
def test_project_session_initialization(self, config_manager_with_projects: ConfigManager):
69+
"""Test that project session initializes with correct default project."""
70+
# Create a new session
71+
test_session = ProjectSession()
72+
73+
# Initialize with default project from config
74+
default_project = config_manager_with_projects.default_project
75+
test_session.initialize(default_project)
76+
77+
assert test_session.get_current_project() == "main"
78+
assert test_session.get_default_project() == "main"
79+
80+
def test_mcp_cli_project_state_mismatch(self, config_manager_with_projects: ConfigManager):
81+
"""Test reproducing the MCP vs CLI project state mismatch."""
82+
# Initialize MCP session
83+
test_session = ProjectSession()
84+
test_session.initialize("main")
85+
86+
# Simulate CLI setting default project to "minerva"
87+
config_manager_with_projects.set_default_project("minerva")
88+
89+
# MCP session should still show "main" because it hasn't been reloaded
90+
assert test_session.get_current_project() == "main"
91+
92+
# Config manager should show "minerva" as default
93+
assert config_manager_with_projects.default_project == "minerva"
94+
95+
# This demonstrates the inconsistency issue
96+
97+
def test_default_project_persistence(self, config_manager_with_projects: ConfigManager, temp_config_dir: Path):
98+
"""Test that default project setting persists correctly."""
99+
# Set default project
100+
config_manager_with_projects.set_default_project("minerva")
101+
102+
# Verify it's saved to config file
103+
config_file = temp_config_dir / "config.json"
104+
assert config_file.exists()
105+
106+
# Create new config manager to simulate fresh load
107+
new_manager = ConfigManager()
108+
new_manager.config_dir = temp_config_dir
109+
new_manager.config_file = config_file
110+
reloaded_config = new_manager.load_config()
111+
112+
assert reloaded_config.default_project == "minerva"
113+
114+
async def test_project_not_found_after_default_change(self, config_manager_with_projects: ConfigManager):
115+
"""Test reproducing the 'Project not found' error after default change."""
116+
# Initialize with main project
117+
test_session = ProjectSession()
118+
test_session.initialize("main")
119+
120+
# Change default in config
121+
config_manager_with_projects.set_default_project("minerva")
122+
123+
# Simulate the status command trying to access the project
124+
# This should fail because session still thinks current project is "main"
125+
# but config now says default is "minerva"
126+
127+
current_from_session = test_session.get_current_project() # Returns "main"
128+
current_from_config = config_manager_with_projects.default_project # Returns "minerva"
129+
130+
assert current_from_session != current_from_config
131+
# This mismatch is what causes the "Project not found" error
132+
133+
def test_edit_note_identifier_resolution_issue(self, config_manager_with_projects: ConfigManager):
134+
"""Test the edit note identifier resolution when project state is inconsistent."""
135+
# Initialize MCP session with one project
136+
test_session = ProjectSession()
137+
test_session.initialize("main")
138+
139+
# Simulate project switch in config but not in session
140+
config_manager_with_projects.set_default_project("minerva")
141+
142+
# When edit_note tries to use get_active_project, it might get confused
143+
# about which project context to use
144+
try:
145+
# This would normally call get_active_project(None)
146+
# which should use session.get_current_project()
147+
active_project = get_active_project(None)
148+
# The project config returned might not match the actual session state
149+
assert active_project.name == "main" # Session state
150+
151+
# But config shows different default
152+
assert config_manager_with_projects.default_project == "minerva"
153+
154+
except Exception as e:
155+
# This might fail if project lookup is inconsistent
156+
pytest.fail(f"get_active_project failed with inconsistent state: {e}")
157+
158+
def test_session_reload_fixes_inconsistency(self, config_manager_with_projects: ConfigManager):
159+
"""Test that reloading session fixes the inconsistency."""
160+
# Initialize session
161+
test_session = ProjectSession()
162+
test_session.initialize("main")
163+
164+
# Change config
165+
config_manager_with_projects.set_default_project("minerva")
166+
167+
# Verify inconsistency
168+
assert test_session.get_current_project() == "main"
169+
assert config_manager_with_projects.default_project == "minerva"
170+
171+
# Reinitialize session with new default
172+
new_default = config_manager_with_projects.default_project
173+
test_session.initialize(new_default)
174+
175+
# Now they should match
176+
assert test_session.get_current_project() == "minerva"
177+
assert config_manager_with_projects.default_project == "minerva"
178+
179+
async def test_project_service_default_setting_propagation(
180+
self,
181+
config_manager_with_projects: ConfigManager,
182+
session_maker,
183+
project_repository: ProjectRepository
184+
):
185+
"""Test that project service properly propagates default changes."""
186+
# Create projects in database to match config
187+
for name, path in config_manager_with_projects.projects.items():
188+
project_data = {
189+
"name": name,
190+
"path": str(path),
191+
"permalink": name.lower().replace(" ", "-"),
192+
"is_active": True,
193+
}
194+
await project_repository.create(project_data)
195+
196+
# Set main as default in database
197+
main_project = await project_repository.get_by_name("main")
198+
await project_repository.set_as_default(main_project.id)
199+
200+
# Initialize project service
201+
project_service = ProjectService(project_repository)
202+
203+
# Change default via service
204+
await project_service.set_default_project("minerva")
205+
206+
# Verify both config and database are updated
207+
assert config_manager_with_projects.default_project == "minerva"
208+
209+
db_default = await project_repository.get_default_project()
210+
assert db_default is not None
211+
assert db_default.name == "minerva"
212+
213+
def test_cli_config_reload_requirement(self, config_manager_with_projects: ConfigManager):
214+
"""Test that CLI commands require config reload after default change."""
215+
# This test verifies the issue mentioned in the CLI command:
216+
# "Reload configuration to apply the change"
217+
218+
# Change default project
219+
original_default = config_manager_with_projects.default_project
220+
config_manager_with_projects.set_default_project("minerva")
221+
222+
# Without reload, global config might still show old value
223+
# This is what causes the inconsistency
224+
assert config_manager_with_projects.default_project == "minerva"
225+
226+
# But global config object might not be updated
227+
# (This would require actual module reload which we can't easily test)
228+
229+
# The fix should ensure config changes are immediately visible
230+
# to all components without requiring restart

0 commit comments

Comments
 (0)