Skip to content
Merged
3 changes: 2 additions & 1 deletion src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ async def list_memory_projects(context: Context | None = None) -> str:
result = "Available projects:\n"

for project in project_list.projects:
result += f"• {project.name}\n"
label = f"{project.display_name} ({project.name})" if project.display_name else project.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep project labels resolvable by resolve endpoint

When display_name is set, this tool now publishes display_name (name) as the project label, but project resolution only accepts external ID, permalink, or raw name (see /v2/projects/resolve lookup in project_router.py), so using the displayed label (or the display name alone) as a project argument will fail with “Project not found”. For private cloud projects this breaks the normal list→select→use workflow unless callers manually parse the slug back out of the label.

Useful? React with 👍 / 👎.

result += f"• {label}\n"

result += "\n" + "─" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/schemas/project_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ class ProjectItem(BaseModel):
name: str
path: str
is_default: bool = False
# Optional metadata injected by cloud hosting layer (not stored in DB)
display_name: Optional[str] = None
is_private: bool = False

@property
def permalink(self) -> str: # pragma: no cover
Expand Down
69 changes: 69 additions & 0 deletions tests/mcp/test_tool_project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,75 @@ async def test_list_memory_projects_unconstrained(app, test_project):
assert f"• {test_project.name}" in result


@pytest.mark.asyncio
async def test_list_memory_projects_shows_display_name(app, client, test_project):
"""When a project has display_name set, list_memory_projects shows 'display_name (name)' format."""
# Inject display_name into the project list response by patching the API response.
# In production, the cloud proxy adds display_name to the JSON before deserialization.
from unittest.mock import AsyncMock, patch
from basic_memory.schemas.project_info import ProjectItem, ProjectList

mock_project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="private-fb83af23",
path="/tmp/private",
is_default=False,
display_name="My Notes",
is_private=True,
)
regular_project = ProjectItem(
id=2,
external_id="00000000-0000-0000-0000-000000000002",
name="main",
path="/tmp/main",
is_default=True,
)
mock_list = ProjectList(
projects=[regular_project, mock_project],
default_project="main",
)

with patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
new_callable=AsyncMock,
return_value=mock_list,
):
result = await list_memory_projects.fn()

# Regular project shows just the name
assert "• main\n" in result
# Private project shows display_name with slug in parentheses
assert "• My Notes (private-fb83af23)" in result


@pytest.mark.asyncio
async def test_list_memory_projects_no_display_name_shows_name_only(app, client, test_project):
"""When a project has no display_name, list_memory_projects shows just the name."""
from unittest.mock import AsyncMock, patch
from basic_memory.schemas.project_info import ProjectItem, ProjectList

project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="my-project",
path="/tmp/my-project",
is_default=True,
)
mock_list = ProjectList(projects=[project], default_project="my-project")

with patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
new_callable=AsyncMock,
return_value=mock_list,
):
result = await list_memory_projects.fn()

assert "• my-project\n" in result
# Should NOT have parenthetical format
assert "(" not in result.split("• my-project")[1].split("\n")[0]


@pytest.mark.asyncio
async def test_list_memory_projects_constrained_env(monkeypatch, app, test_project):
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
Expand Down
100 changes: 100 additions & 0 deletions tests/schemas/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,106 @@ class TestModel(BaseModel):
assert time_diff < 3600, f"'today' and '1d' should be similar times, diff: {time_diff}s"


class TestProjectItemSchema:
"""Test ProjectItem schema with optional cloud-injected fields."""

def test_project_item_defaults(self):
"""ProjectItem has sensible defaults for cloud-injected fields."""
from basic_memory.schemas.project_info import ProjectItem

project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="main",
path="/tmp/main",
)
assert project.display_name is None
assert project.is_private is False
assert project.is_default is False

def test_project_item_with_display_name(self):
"""ProjectItem accepts display_name from cloud proxy enrichment."""
from basic_memory.schemas.project_info import ProjectItem

project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="private-fb83af23",
path="/tmp/private",
display_name="My Notes",
is_private=True,
)
assert project.display_name == "My Notes"
assert project.is_private is True
assert project.name == "private-fb83af23"

def test_project_item_deserialization_from_json(self):
"""ProjectItem correctly deserializes display_name and is_private from JSON.

This is the actual path: the cloud proxy enriches the JSON response from
basic-memory API, and the MCP tools deserialize it back into ProjectItem.
"""
from basic_memory.schemas.project_info import ProjectItem

json_data = {
"id": 1,
"external_id": "00000000-0000-0000-0000-000000000001",
"name": "private-fb83af23",
"path": "/tmp/private",
"is_default": False,
"display_name": "My Notes",
"is_private": True,
}
project = ProjectItem.model_validate(json_data)
assert project.display_name == "My Notes"
assert project.is_private is True

def test_project_item_deserialization_without_cloud_fields(self):
"""ProjectItem works when cloud fields are absent (non-cloud usage)."""
from basic_memory.schemas.project_info import ProjectItem

json_data = {
"id": 1,
"external_id": "00000000-0000-0000-0000-000000000001",
"name": "main",
"path": "/tmp/main",
"is_default": True,
}
project = ProjectItem.model_validate(json_data)
assert project.display_name is None
assert project.is_private is False

def test_project_list_with_mixed_projects(self):
"""ProjectList can contain a mix of regular and private projects."""
from basic_memory.schemas.project_info import ProjectItem, ProjectList

projects = ProjectList(
projects=[
ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="main",
path="/tmp/main",
is_default=True,
),
ProjectItem(
id=2,
external_id="00000000-0000-0000-0000-000000000002",
name="private-fb83af23",
path="/tmp/private",
display_name="My Notes",
is_private=True,
),
],
default_project="main",
)
assert len(projects.projects) == 2
assert projects.projects[0].display_name is None
assert projects.projects[0].is_private is False
assert projects.projects[1].display_name == "My Notes"
assert projects.projects[1].is_private is True


class TestObservationContentLength:
"""Test observation content length validation matches DB schema."""

Expand Down
Loading