Skip to content

Commit fedcbff

Browse files
jope-bmclaude
andcommitted
feat: Add display_name and is_private fields to ProjectItem
Add optional cloud-injected metadata fields to ProjectItem schema so the cloud proxy can enrich project list responses with friendly names and privacy indicators for per-user private projects. - display_name: Optional friendly name (e.g. "My Notes") shown instead of the internal slug (e.g. "private-fb83af23") - is_private: Boolean flag indicating the project is private to the user Update list_memory_projects MCP tool to show "display_name (name)" format when display_name is present, so MCP clients see friendly names while still knowing the actual project slug to use in API calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Joe P <joe@basicmemory.com>
1 parent 8c05a9e commit fedcbff

4 files changed

Lines changed: 174 additions & 1 deletion

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ async def list_memory_projects(context: Context | None = None) -> str:
5858
result = "Available projects:\n"
5959

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

6364
result += "\n" + "─" * 40 + "\n"
6465
result += "Next: Ask which project to use for this session.\n"

src/basic_memory/schemas/project_info.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ class ProjectItem(BaseModel):
178178
name: str
179179
path: str
180180
is_default: bool = False
181+
# Optional metadata injected by cloud hosting layer (not stored in DB)
182+
display_name: Optional[str] = None
183+
is_private: bool = False
181184

182185
@property
183186
def permalink(self) -> str: # pragma: no cover

tests/mcp/test_tool_project_management.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,75 @@ async def test_list_memory_projects_unconstrained(app, test_project):
1515
assert f"• {test_project.name}" in result
1616

1717

18+
@pytest.mark.asyncio
19+
async def test_list_memory_projects_shows_display_name(app, client, test_project):
20+
"""When a project has display_name set, list_memory_projects shows 'display_name (name)' format."""
21+
# Inject display_name into the project list response by patching the API response.
22+
# In production, the cloud proxy adds display_name to the JSON before deserialization.
23+
from unittest.mock import AsyncMock, patch
24+
from basic_memory.schemas.project_info import ProjectItem, ProjectList
25+
26+
mock_project = ProjectItem(
27+
id=1,
28+
external_id="00000000-0000-0000-0000-000000000001",
29+
name="private-fb83af23",
30+
path="/tmp/private",
31+
is_default=False,
32+
display_name="My Notes",
33+
is_private=True,
34+
)
35+
regular_project = ProjectItem(
36+
id=2,
37+
external_id="00000000-0000-0000-0000-000000000002",
38+
name="main",
39+
path="/tmp/main",
40+
is_default=True,
41+
)
42+
mock_list = ProjectList(
43+
projects=[regular_project, mock_project],
44+
default_project="main",
45+
)
46+
47+
with patch(
48+
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
49+
new_callable=AsyncMock,
50+
return_value=mock_list,
51+
):
52+
result = await list_memory_projects.fn()
53+
54+
# Regular project shows just the name
55+
assert "• main\n" in result
56+
# Private project shows display_name with slug in parentheses
57+
assert "• My Notes (private-fb83af23)" in result
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_list_memory_projects_no_display_name_shows_name_only(app, client, test_project):
62+
"""When a project has no display_name, list_memory_projects shows just the name."""
63+
from unittest.mock import AsyncMock, patch
64+
from basic_memory.schemas.project_info import ProjectItem, ProjectList
65+
66+
project = ProjectItem(
67+
id=1,
68+
external_id="00000000-0000-0000-0000-000000000001",
69+
name="my-project",
70+
path="/tmp/my-project",
71+
is_default=True,
72+
)
73+
mock_list = ProjectList(projects=[project], default_project="my-project")
74+
75+
with patch(
76+
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
77+
new_callable=AsyncMock,
78+
return_value=mock_list,
79+
):
80+
result = await list_memory_projects.fn()
81+
82+
assert "• my-project\n" in result
83+
# Should NOT have parenthetical format
84+
assert "(" not in result.split("• my-project")[1].split("\n")[0]
85+
86+
1887
@pytest.mark.asyncio
1988
async def test_list_memory_projects_constrained_env(monkeypatch, app, test_project):
2089
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)

tests/schemas/test_schemas.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,106 @@ class TestModel(BaseModel):
511511
assert time_diff < 3600, f"'today' and '1d' should be similar times, diff: {time_diff}s"
512512

513513

514+
class TestProjectItemSchema:
515+
"""Test ProjectItem schema with optional cloud-injected fields."""
516+
517+
def test_project_item_defaults(self):
518+
"""ProjectItem has sensible defaults for cloud-injected fields."""
519+
from basic_memory.schemas.project_info import ProjectItem
520+
521+
project = ProjectItem(
522+
id=1,
523+
external_id="00000000-0000-0000-0000-000000000001",
524+
name="main",
525+
path="/tmp/main",
526+
)
527+
assert project.display_name is None
528+
assert project.is_private is False
529+
assert project.is_default is False
530+
531+
def test_project_item_with_display_name(self):
532+
"""ProjectItem accepts display_name from cloud proxy enrichment."""
533+
from basic_memory.schemas.project_info import ProjectItem
534+
535+
project = ProjectItem(
536+
id=1,
537+
external_id="00000000-0000-0000-0000-000000000001",
538+
name="private-fb83af23",
539+
path="/tmp/private",
540+
display_name="My Notes",
541+
is_private=True,
542+
)
543+
assert project.display_name == "My Notes"
544+
assert project.is_private is True
545+
assert project.name == "private-fb83af23"
546+
547+
def test_project_item_deserialization_from_json(self):
548+
"""ProjectItem correctly deserializes display_name and is_private from JSON.
549+
550+
This is the actual path: the cloud proxy enriches the JSON response from
551+
basic-memory API, and the MCP tools deserialize it back into ProjectItem.
552+
"""
553+
from basic_memory.schemas.project_info import ProjectItem
554+
555+
json_data = {
556+
"id": 1,
557+
"external_id": "00000000-0000-0000-0000-000000000001",
558+
"name": "private-fb83af23",
559+
"path": "/tmp/private",
560+
"is_default": False,
561+
"display_name": "My Notes",
562+
"is_private": True,
563+
}
564+
project = ProjectItem.model_validate(json_data)
565+
assert project.display_name == "My Notes"
566+
assert project.is_private is True
567+
568+
def test_project_item_deserialization_without_cloud_fields(self):
569+
"""ProjectItem works when cloud fields are absent (non-cloud usage)."""
570+
from basic_memory.schemas.project_info import ProjectItem
571+
572+
json_data = {
573+
"id": 1,
574+
"external_id": "00000000-0000-0000-0000-000000000001",
575+
"name": "main",
576+
"path": "/tmp/main",
577+
"is_default": True,
578+
}
579+
project = ProjectItem.model_validate(json_data)
580+
assert project.display_name is None
581+
assert project.is_private is False
582+
583+
def test_project_list_with_mixed_projects(self):
584+
"""ProjectList can contain a mix of regular and private projects."""
585+
from basic_memory.schemas.project_info import ProjectItem, ProjectList
586+
587+
projects = ProjectList(
588+
projects=[
589+
ProjectItem(
590+
id=1,
591+
external_id="00000000-0000-0000-0000-000000000001",
592+
name="main",
593+
path="/tmp/main",
594+
is_default=True,
595+
),
596+
ProjectItem(
597+
id=2,
598+
external_id="00000000-0000-0000-0000-000000000002",
599+
name="private-fb83af23",
600+
path="/tmp/private",
601+
display_name="My Notes",
602+
is_private=True,
603+
),
604+
],
605+
default_project="main",
606+
)
607+
assert len(projects.projects) == 2
608+
assert projects.projects[0].display_name is None
609+
assert projects.projects[0].is_private is False
610+
assert projects.projects[1].display_name == "My Notes"
611+
assert projects.projects[1].is_private is True
612+
613+
514614
class TestObservationContentLength:
515615
"""Test observation content length validation matches DB schema."""
516616

0 commit comments

Comments
 (0)