diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 4297a341..c9ab726a 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -120,7 +120,7 @@ def set_default_project( try: project_name = generate_permalink(name) - response = asyncio.run(call_put(client, f"projects/{project_name}/default")) + response = asyncio.run(call_put(client, f"/projects/{project_name}/default")) result = ProjectStatusResponse.model_validate(response.json()) console.print(f"[green]{result.message}[/green]") @@ -128,12 +128,8 @@ def set_default_project( console.print(f"[red]Error setting default project: {str(e)}[/red]") raise typer.Exit(1) - # Reload configuration to apply the change - from importlib import reload - from basic_memory import config as config_module - - reload(config_module) - + # The API call above should have updated both config and MCP session + # No need for manual reload - the project service handles this automatically console.print("[green]Project activated for current session[/green]") diff --git a/src/basic_memory/mcp/project_session.py b/src/basic_memory/mcp/project_session.py index 059cbf3e..f5104f64 100644 --- a/src/basic_memory/mcp/project_session.py +++ b/src/basic_memory/mcp/project_session.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import Optional from loguru import logger -from basic_memory.config import ProjectConfig, get_project_config +from basic_memory.config import ProjectConfig, get_project_config, config_manager @dataclass @@ -64,6 +64,21 @@ class ProjectSession: self.current_project = self.default_project # pragma: no cover logger.info(f"Reset project context to default: {self.default_project}") # pragma: no cover + def refresh_from_config(self) -> None: + """Refresh session state from current configuration. + + This method reloads the default project from config and reinitializes + the session. This should be called when the default project is changed + via CLI or API to ensure MCP session stays in sync. + """ + # Reload config to get latest default project + current_config = config_manager.load_config() + new_default = current_config.default_project + + # Reinitialize with new default + self.initialize(new_default) + logger.info(f"Refreshed project session from config, new default: {new_default}") + # Global session instance session = ProjectSession() diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index f04109d7..f92f672c 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -153,6 +153,14 @@ class ProjectService: logger.error(f"Project '{name}' exists in config but not in database") logger.info(f"Project '{name}' set as default in configuration and database") + + # Refresh MCP session to pick up the new default project + try: + from basic_memory.mcp.project_session import session + session.refresh_from_config() + except ImportError: + # MCP components might not be available in all contexts (e.g., CLI-only usage) + logger.debug("MCP session not available, skipping session refresh") async def _ensure_single_default_project(self) -> None: """Ensure only one project has is_default=True. @@ -273,6 +281,14 @@ class ProjectService: await self.repository.set_as_default(project.id) logger.info("Project synchronization complete") + + # Refresh MCP session to ensure it's in sync with current config + try: + from basic_memory.mcp.project_session import session + session.refresh_from_config() + except ImportError: + # MCP components might not be available in all contexts + logger.debug("MCP session not available, skipping session refresh") async def update_project( # pragma: no cover self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None diff --git a/test-int/conftest.py b/test-int/conftest.py index 6694d292..24ef8de9 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -148,6 +148,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> C # Patch the config_manager in all locations where it's imported monkeypatch.setattr("basic_memory.config.config_manager", config_manager) monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager) + monkeypatch.setattr("basic_memory.mcp.project_session.config_manager", config_manager) return config_manager diff --git a/test-int/mcp/test_project_state_sync_integration.py b/test-int/mcp/test_project_state_sync_integration.py new file mode 100644 index 00000000..63a7d238 --- /dev/null +++ b/test-int/mcp/test_project_state_sync_integration.py @@ -0,0 +1,173 @@ +"""Integration test for project state synchronization between MCP session and CLI config. + +This test validates the fix for GitHub issue #148 where MCP session and CLI commands +had inconsistent project state, causing "Project not found" errors and edit failures. + +The test simulates the exact workflow reported in the issue: +1. MCP server starts with a default project +2. Default project is changed via CLI/API +3. MCP tools should immediately use the new project (no restart needed) +4. All operations should work consistently in the new project context +""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_project_state_sync_after_default_change(mcp_server, app, config_manager): + """Test that MCP session stays in sync when default project is changed.""" + + async with Client(mcp_server) as client: + # Step 1: Verify initial state - MCP should show test-project as current + initial_result = await client.call_tool("get_current_project", {}) + assert len(initial_result) == 1 + assert "Current project: test-project" in initial_result[0].text + + # Step 2: Create a second project that we can switch to + create_result = await client.call_tool( + "create_memory_project", + { + "project_name": "minerva", + "project_path": "/tmp/minerva-test-project", + "set_default": False, # Don't set as default yet + }, + ) + assert len(create_result) == 1 + assert "✓" in create_result[0].text + assert "minerva" in create_result[0].text + + # Step 3: Change default project to minerva via set_default_project tool + # This simulates the CLI command `basic-memory project default minerva` + set_default_result = await client.call_tool( + "set_default_project", {"project_name": "minerva"} + ) + assert len(set_default_result) == 1 + assert "✓" in set_default_result[0].text + assert "minerva" in set_default_result[0].text + + # Step 4: Verify MCP session immediately reflects the change (no restart needed) + # This tests the fix - session.refresh_from_config() should have been called + updated_result = await client.call_tool("get_current_project", {}) + assert len(updated_result) == 1 + + # The fix should ensure these are consistent now: + updated_text = updated_result[0].text + assert "Current project: minerva" in updated_text + + # Step 5: Verify config manager also shows the new default + assert config_manager.default_project == "minerva" + + # Step 6: Test that note operations work in the new project context + # This validates that the identifier resolution works correctly + write_result = await client.call_tool( + "write_note", + { + "title": "Test Consistency Note", + "folder": "test", + "content": "# Test Note\n\nThis note tests project state consistency.\n\n- [test] Project state sync working", + "tags": "test,consistency", + }, + ) + assert len(write_result) == 1 + assert "Test Consistency Note" in write_result[0].text + + # Step 7: Test that we can read the note we just created + read_result = await client.call_tool("read_note", {"identifier": "Test Consistency Note"}) + assert len(read_result) == 1 + assert "Test Consistency Note" in read_result[0].text + assert "project state sync working" in read_result[0].text.lower() + + # Step 8: Test that edit operations work (this was failing in the original issue) + edit_result = await client.call_tool( + "edit_note", + { + "identifier": "Test Consistency Note", + "operation": "append", + "content": "\n\n## Update\n\nEdit operation successful after project switch!", + }, + ) + assert len(edit_result) == 1 + assert "added" in edit_result[0].text.lower() and "lines" in edit_result[0].text.lower() + + # Step 9: Verify the edit was applied + final_read_result = await client.call_tool( + "read_note", {"identifier": "Test Consistency Note"} + ) + assert len(final_read_result) == 1 + final_content = final_read_result[0].text + assert "Edit operation successful" in final_content + + # Clean up - switch back to test-project + await client.call_tool("switch_project", {"project_name": "test-project"}) + + +@pytest.mark.asyncio +async def test_multiple_project_switches_maintain_consistency(mcp_server, app, config_manager): + """Test that multiple project switches maintain consistent state.""" + + async with Client(mcp_server) as client: + # Create multiple test projects + for project_name in ["project-a", "project-b", "project-c"]: + await client.call_tool( + "create_memory_project", + { + "project_name": project_name, + "project_path": f"/tmp/{project_name}", + "set_default": False, + }, + ) + + # Test switching between projects multiple times + for project_name in ["project-a", "project-b", "project-c", "test-project"]: + # Set as default + set_result = await client.call_tool( + "set_default_project", {"project_name": project_name} + ) + assert "✓" in set_result[0].text + + # Verify MCP session immediately reflects the change + current_result = await client.call_tool("get_current_project", {}) + assert f"Current project: {project_name}" in current_result[0].text + + # Verify config is also updated + assert config_manager.default_project == project_name + + # Test that operations work in this project + note_title = f"Note in {project_name}" + write_result = await client.call_tool( + "write_note", + { + "title": note_title, + "folder": "test", + "content": f"# {note_title}\n\nTesting operations in {project_name}.", + "tags": "test", + }, + ) + assert note_title in write_result[0].text + + # Clean up - switch back to test-project + await client.call_tool("set_default_project", {"project_name": "test-project"}) + + +@pytest.mark.asyncio +async def test_session_handles_nonexistent_project_gracefully(mcp_server, app): + """Test that session handles attempts to switch to nonexistent projects gracefully.""" + + async with Client(mcp_server) as client: + # Try to switch to a project that doesn't exist + switch_result = await client.call_tool( + "switch_project", {"project_name": "nonexistent-project"} + ) + assert len(switch_result) == 1 + result_text = switch_result[0].text + + # Should show an error message + assert "Error:" in result_text + assert "not found" in result_text.lower() + assert "Available projects:" in result_text + assert "test-project" in result_text # Should list available projects + + # Verify the session stays on the original project + current_result = await client.call_tool("get_current_project", {}) + assert "Current project: test-project" in current_result[0].text