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>
This commit is contained in:
Paul Hernandez
2025-06-19 21:24:51 -05:00
committed by GitHub
parent 7be001ca68
commit 35e4f73ae8
5 changed files with 209 additions and 8 deletions
+3 -7
View File
@@ -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]")
+16 -1
View File
@@ -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()
@@ -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
+1
View File
@@ -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
@@ -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