Skip to content

Commit 35e4f73

Browse files
fix: resolve project state inconsistency between MCP and CLI (#149)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
1 parent 7be001c commit 35e4f73

5 files changed

Lines changed: 209 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

test-int/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> C
148148
# Patch the config_manager in all locations where it's imported
149149
monkeypatch.setattr("basic_memory.config.config_manager", config_manager)
150150
monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager)
151+
monkeypatch.setattr("basic_memory.mcp.project_session.config_manager", config_manager)
151152

152153
return config_manager
153154

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Integration test for project state synchronization between MCP session and CLI config.
2+
3+
This test validates the fix for GitHub issue #148 where MCP session and CLI commands
4+
had inconsistent project state, causing "Project not found" errors and edit failures.
5+
6+
The test simulates the exact workflow reported in the issue:
7+
1. MCP server starts with a default project
8+
2. Default project is changed via CLI/API
9+
3. MCP tools should immediately use the new project (no restart needed)
10+
4. All operations should work consistently in the new project context
11+
"""
12+
13+
import pytest
14+
from fastmcp import Client
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_project_state_sync_after_default_change(mcp_server, app, config_manager):
19+
"""Test that MCP session stays in sync when default project is changed."""
20+
21+
async with Client(mcp_server) as client:
22+
# Step 1: Verify initial state - MCP should show test-project as current
23+
initial_result = await client.call_tool("get_current_project", {})
24+
assert len(initial_result) == 1
25+
assert "Current project: test-project" in initial_result[0].text
26+
27+
# Step 2: Create a second project that we can switch to
28+
create_result = await client.call_tool(
29+
"create_memory_project",
30+
{
31+
"project_name": "minerva",
32+
"project_path": "/tmp/minerva-test-project",
33+
"set_default": False, # Don't set as default yet
34+
},
35+
)
36+
assert len(create_result) == 1
37+
assert "✓" in create_result[0].text
38+
assert "minerva" in create_result[0].text
39+
40+
# Step 3: Change default project to minerva via set_default_project tool
41+
# This simulates the CLI command `basic-memory project default minerva`
42+
set_default_result = await client.call_tool(
43+
"set_default_project", {"project_name": "minerva"}
44+
)
45+
assert len(set_default_result) == 1
46+
assert "✓" in set_default_result[0].text
47+
assert "minerva" in set_default_result[0].text
48+
49+
# Step 4: Verify MCP session immediately reflects the change (no restart needed)
50+
# This tests the fix - session.refresh_from_config() should have been called
51+
updated_result = await client.call_tool("get_current_project", {})
52+
assert len(updated_result) == 1
53+
54+
# The fix should ensure these are consistent now:
55+
updated_text = updated_result[0].text
56+
assert "Current project: minerva" in updated_text
57+
58+
# Step 5: Verify config manager also shows the new default
59+
assert config_manager.default_project == "minerva"
60+
61+
# Step 6: Test that note operations work in the new project context
62+
# This validates that the identifier resolution works correctly
63+
write_result = await client.call_tool(
64+
"write_note",
65+
{
66+
"title": "Test Consistency Note",
67+
"folder": "test",
68+
"content": "# Test Note\n\nThis note tests project state consistency.\n\n- [test] Project state sync working",
69+
"tags": "test,consistency",
70+
},
71+
)
72+
assert len(write_result) == 1
73+
assert "Test Consistency Note" in write_result[0].text
74+
75+
# Step 7: Test that we can read the note we just created
76+
read_result = await client.call_tool("read_note", {"identifier": "Test Consistency Note"})
77+
assert len(read_result) == 1
78+
assert "Test Consistency Note" in read_result[0].text
79+
assert "project state sync working" in read_result[0].text.lower()
80+
81+
# Step 8: Test that edit operations work (this was failing in the original issue)
82+
edit_result = await client.call_tool(
83+
"edit_note",
84+
{
85+
"identifier": "Test Consistency Note",
86+
"operation": "append",
87+
"content": "\n\n## Update\n\nEdit operation successful after project switch!",
88+
},
89+
)
90+
assert len(edit_result) == 1
91+
assert "added" in edit_result[0].text.lower() and "lines" in edit_result[0].text.lower()
92+
93+
# Step 9: Verify the edit was applied
94+
final_read_result = await client.call_tool(
95+
"read_note", {"identifier": "Test Consistency Note"}
96+
)
97+
assert len(final_read_result) == 1
98+
final_content = final_read_result[0].text
99+
assert "Edit operation successful" in final_content
100+
101+
# Clean up - switch back to test-project
102+
await client.call_tool("switch_project", {"project_name": "test-project"})
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_multiple_project_switches_maintain_consistency(mcp_server, app, config_manager):
107+
"""Test that multiple project switches maintain consistent state."""
108+
109+
async with Client(mcp_server) as client:
110+
# Create multiple test projects
111+
for project_name in ["project-a", "project-b", "project-c"]:
112+
await client.call_tool(
113+
"create_memory_project",
114+
{
115+
"project_name": project_name,
116+
"project_path": f"/tmp/{project_name}",
117+
"set_default": False,
118+
},
119+
)
120+
121+
# Test switching between projects multiple times
122+
for project_name in ["project-a", "project-b", "project-c", "test-project"]:
123+
# Set as default
124+
set_result = await client.call_tool(
125+
"set_default_project", {"project_name": project_name}
126+
)
127+
assert "✓" in set_result[0].text
128+
129+
# Verify MCP session immediately reflects the change
130+
current_result = await client.call_tool("get_current_project", {})
131+
assert f"Current project: {project_name}" in current_result[0].text
132+
133+
# Verify config is also updated
134+
assert config_manager.default_project == project_name
135+
136+
# Test that operations work in this project
137+
note_title = f"Note in {project_name}"
138+
write_result = await client.call_tool(
139+
"write_note",
140+
{
141+
"title": note_title,
142+
"folder": "test",
143+
"content": f"# {note_title}\n\nTesting operations in {project_name}.",
144+
"tags": "test",
145+
},
146+
)
147+
assert note_title in write_result[0].text
148+
149+
# Clean up - switch back to test-project
150+
await client.call_tool("set_default_project", {"project_name": "test-project"})
151+
152+
153+
@pytest.mark.asyncio
154+
async def test_session_handles_nonexistent_project_gracefully(mcp_server, app):
155+
"""Test that session handles attempts to switch to nonexistent projects gracefully."""
156+
157+
async with Client(mcp_server) as client:
158+
# Try to switch to a project that doesn't exist
159+
switch_result = await client.call_tool(
160+
"switch_project", {"project_name": "nonexistent-project"}
161+
)
162+
assert len(switch_result) == 1
163+
result_text = switch_result[0].text
164+
165+
# Should show an error message
166+
assert "Error:" in result_text
167+
assert "not found" in result_text.lower()
168+
assert "Available projects:" in result_text
169+
assert "test-project" in result_text # Should list available projects
170+
171+
# Verify the session stays on the original project
172+
current_result = await client.call_tool("get_current_project", {})
173+
assert "Current project: test-project" in current_result[0].text

0 commit comments

Comments
 (0)