Skip to content

Commit 4861331

Browse files
committed
fix(mcp): normalize personal workspace memory urls
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 4568571 commit 4861331

2 files changed

Lines changed: 149 additions & 3 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,30 @@ def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str
422422
return workspace_slug, project_identifier, remainder
423423

424424

425+
def _canonical_memory_path_for_workspace(
426+
*,
427+
workspace_slug: str,
428+
workspace_type: str,
429+
project_permalink: str,
430+
remainder: str,
431+
include_project: bool,
432+
) -> str:
433+
"""Return the stored canonical path for a workspace-qualified memory URL."""
434+
normalized_remainder = remainder.strip("/")
435+
if workspace_type == "organization":
436+
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
437+
elif workspace_type == "personal":
438+
prefix = project_permalink if include_project else ""
439+
else:
440+
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
441+
442+
if not prefix:
443+
return normalized_remainder
444+
if not normalized_remainder:
445+
return prefix
446+
return f"{prefix}/{normalized_remainder}"
447+
448+
425449
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
426450
"""Return True when workspace discovery can be used without forcing local routing."""
427451
from basic_memory.mcp.async_client import (
@@ -496,7 +520,13 @@ async def resolve_workspace_qualified_memory_url(
496520
)
497521

498522
entry = matches[0]
499-
canonical_path = f"{entry.workspace.slug}/{entry.project.permalink}/{remainder}"
523+
canonical_path = _canonical_memory_path_for_workspace(
524+
workspace_slug=entry.workspace.slug,
525+
workspace_type=entry.workspace.workspace_type,
526+
project_permalink=entry.project.permalink,
527+
remainder=remainder,
528+
include_project=ConfigManager().config.permalinks_include_project,
529+
)
500530
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
501531

502532

@@ -1005,7 +1035,19 @@ async def resolve_project_and_path(
10051035
if normalized_path == qualified_prefix or normalized_path.startswith(
10061036
f"{qualified_prefix}/"
10071037
):
1008-
return cached_project, normalized_path, True
1038+
remainder = (
1039+
""
1040+
if normalized_path == qualified_prefix
1041+
else normalized_path.removeprefix(f"{qualified_prefix}/")
1042+
)
1043+
resolved_path = _canonical_memory_path_for_workspace(
1044+
workspace_slug=cached_workspace.slug,
1045+
workspace_type=cached_workspace.workspace_type,
1046+
project_permalink=cached_project.permalink,
1047+
remainder=remainder,
1048+
include_project=bool(include_project),
1049+
)
1050+
return cached_project, resolved_path, True
10091051

10101052
workspace_context = current_workspace_permalink_context()
10111053
if workspace_context and project:
@@ -1016,7 +1058,19 @@ async def resolve_project_and_path(
10161058
f"{qualified_prefix}/"
10171059
):
10181060
active_project = await get_active_project(client, project, context, headers)
1019-
return active_project, normalized_path, True
1061+
remainder = (
1062+
""
1063+
if normalized_path == qualified_prefix
1064+
else normalized_path.removeprefix(f"{qualified_prefix}/")
1065+
)
1066+
resolved_path = _canonical_memory_path_for_workspace(
1067+
workspace_slug=workspace_context.workspace_slug,
1068+
workspace_type=workspace_context.workspace_type,
1069+
project_permalink=project_permalink,
1070+
remainder=remainder,
1071+
include_project=bool(include_project),
1072+
)
1073+
return active_project, resolved_path, True
10201074

10211075
project_prefix, remainder = _split_project_prefix(normalized_path)
10221076
include_project = config.permalinks_include_project

tests/mcp/test_project_context.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,49 @@ async def fake_index(context=None):
786786
await resolve_workspace_qualified_memory_url("memory://team-paul/main/notes/foo")
787787

788788

789+
@pytest.mark.asyncio
790+
async def test_resolve_workspace_qualified_memory_url_uses_personal_canonical_path(
791+
config_manager,
792+
monkeypatch,
793+
):
794+
import basic_memory.mcp.project_context as project_context
795+
from basic_memory.mcp.project_context import (
796+
WorkspaceProjectEntry,
797+
_build_workspace_project_index,
798+
resolve_workspace_qualified_memory_url,
799+
)
800+
801+
config = config_manager.load_config()
802+
config.permalinks_include_project = True
803+
config_manager.save_config(config)
804+
805+
personal = _workspace(
806+
tenant_id="personal-tenant",
807+
workspace_type="personal",
808+
slug="personal",
809+
name="Personal",
810+
role="owner",
811+
is_default=True,
812+
)
813+
entries = (
814+
WorkspaceProjectEntry(
815+
workspace=personal,
816+
project=_project("main", id=1, external_id="personal-main-id"),
817+
),
818+
)
819+
index = _build_workspace_project_index((personal,), entries)
820+
821+
async def fake_index(context=None):
822+
return index
823+
824+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
825+
826+
resolved = await resolve_workspace_qualified_memory_url("memory://personal/main/notes/foo")
827+
828+
assert resolved is not None
829+
assert resolved.canonical_path == "main/notes/foo"
830+
831+
789832
@pytest.mark.asyncio
790833
async def test_get_project_client_routes_duplicate_project_through_workspace_slug(
791834
config_manager,
@@ -1287,6 +1330,55 @@ async def fake_call_post(*args, **kwargs):
12871330
assert is_memory_url is True
12881331

12891332

1333+
@pytest.mark.asyncio
1334+
async def test_resolve_project_and_path_strips_personal_workspace_prefix(
1335+
config_manager,
1336+
monkeypatch,
1337+
):
1338+
from mcp.server.fastmcp.exceptions import ToolError
1339+
1340+
from basic_memory.mcp.project_context import resolve_project_and_path
1341+
from basic_memory.schemas.project_info import ProjectItem
1342+
1343+
config = config_manager.load_config()
1344+
config.permalinks_include_project = True
1345+
config_manager.save_config(config)
1346+
1347+
context = _ContextState()
1348+
cached_project = ProjectItem(
1349+
id=1,
1350+
external_id="11111111-1111-1111-1111-111111111111",
1351+
name="main",
1352+
path="/tmp/main",
1353+
is_default=False,
1354+
)
1355+
personal_workspace = _workspace(
1356+
tenant_id="personal-tenant",
1357+
workspace_type="personal",
1358+
slug="personal",
1359+
name="Personal",
1360+
role="owner",
1361+
is_default=True,
1362+
)
1363+
await context.set_state("active_project", cached_project.model_dump())
1364+
await context.set_state("active_workspace", personal_workspace.model_dump())
1365+
1366+
async def fake_call_post(*args, **kwargs):
1367+
raise ToolError("project not found")
1368+
1369+
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
1370+
1371+
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
1372+
client=cast(Any, None),
1373+
identifier="memory://personal/main/notes/foo",
1374+
context=_ctx(context),
1375+
)
1376+
1377+
assert active_project == cached_project
1378+
assert resolved_path == "main/notes/foo"
1379+
assert is_memory_url is True
1380+
1381+
12901382
@pytest.mark.asyncio
12911383
async def test_resolve_project_and_path_preserves_existing_project_prefixed_memory_url(
12921384
config_manager,

0 commit comments

Comments
 (0)