Skip to content

Commit e2a830b

Browse files
committed
fix(mcp): resolve legacy workspace permalinks
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a28213a commit e2a830b

2 files changed

Lines changed: 107 additions & 17 deletions

File tree

src/basic_memory/mcp/tools/read_note.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from basic_memory.mcp.server import mcp
1919
from basic_memory.mcp.tools.search import search_notes
2020
from basic_memory.schemas.memory import memory_url_path
21-
from basic_memory.utils import validate_project_path
21+
from basic_memory.utils import generate_permalink, validate_project_path
22+
from basic_memory.workspace_context import current_workspace_permalink_context
2223

2324

2425
def _is_exact_title_match(identifier: str, title: str) -> bool:
@@ -277,24 +278,51 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
277278
value = item.get("file_path")
278279
return str(value) if value else None
279280

280-
try:
281-
# Try to resolve identifier to entity ID
282-
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
281+
def _legacy_workspace_unqualified_path(path: str) -> str | None:
282+
workspace_context = current_workspace_permalink_context()
283+
if workspace_context is None:
284+
return None
285+
286+
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
287+
project_prefix = active_project.permalink
288+
qualified_prefix = f"{workspace_prefix}/{project_prefix}"
289+
normalized_path = path.strip("/")
290+
if normalized_path == qualified_prefix:
291+
return project_prefix
292+
if normalized_path.startswith(f"{qualified_prefix}/"):
293+
return f"{project_prefix}/{normalized_path.removeprefix(f'{qualified_prefix}/')}"
294+
return None
295+
296+
direct_lookup_paths = [entity_path]
297+
legacy_path = _legacy_workspace_unqualified_path(entity_path)
298+
if legacy_path and legacy_path not in direct_lookup_paths:
299+
# Trigger: existing cloud rows may still use project-prefixed permalinks.
300+
# Why: new workspace-qualified IDs should read old notes without a re-sync.
301+
# Outcome: try the legacy path after the canonical workspace path misses.
302+
direct_lookup_paths.append(legacy_path)
303+
304+
for direct_lookup_path in direct_lookup_paths:
305+
try:
306+
# Try to resolve identifier to entity ID
307+
entity_id = await knowledge_client.resolve_entity(
308+
direct_lookup_path, strict=True
309+
)
283310

284-
# Fetch content using entity ID
285-
response = await resource_client.read(entity_id)
311+
# Fetch content using entity ID
312+
response = await resource_client.read(entity_id)
286313

287-
# If successful, return the content
288-
if response.status_code == 200:
289-
logger.info(
290-
"Returning read_note result from resource: {path}", path=entity_path
291-
)
292-
if output_format == "json":
293-
return await _read_json_payload(entity_id)
294-
return response.text
295-
except Exception as e: # pragma: no cover
296-
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
297-
# Continue to fallback methods
314+
# If successful, return the content
315+
if response.status_code == 200:
316+
logger.info(
317+
"Returning read_note result from resource: {path}",
318+
path=direct_lookup_path,
319+
)
320+
if output_format == "json":
321+
return await _read_json_payload(entity_id)
322+
return response.text
323+
except Exception as e: # pragma: no cover
324+
logger.info(f"Direct lookup failed for '{direct_lookup_path}': {e}")
325+
# Continue to alternate direct lookup paths, then fallback methods
298326

299327
# Fallback 1: Try title search via API
300328
logger.info(f"Search title for: {identifier}")

tests/mcp/test_tool_read_note.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,68 @@ async def test_personal_workspace_write_stores_complete_canonical_permalink(
404404
assert stored.permalink == expected_permalink
405405

406406

407+
@pytest.mark.asyncio
408+
async def test_read_note_personal_workspace_keeps_short_project_permalink_working(
409+
app,
410+
test_project,
411+
):
412+
from basic_memory.workspace_context import workspace_permalink_context
413+
414+
legacy_permalink = f"{test_project.name}/personal/short-personal-note"
415+
416+
await write_note(
417+
project=test_project.name,
418+
title="Short Personal Note",
419+
directory="personal",
420+
content="Short personal workspace content",
421+
)
422+
423+
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
424+
read_result = await read_note(
425+
f"memory://{legacy_permalink}",
426+
project=test_project.name,
427+
output_format="json",
428+
)
429+
430+
assert isinstance(read_result, dict)
431+
assert read_result["permalink"] == legacy_permalink
432+
assert read_result["content"].strip() == "Short personal workspace content"
433+
434+
435+
@pytest.mark.asyncio
436+
async def test_read_note_workspace_qualified_personal_url_finds_legacy_short_permalink(
437+
app,
438+
test_project,
439+
entity_repository,
440+
):
441+
from basic_memory.workspace_context import workspace_permalink_context
442+
443+
legacy_permalink = f"{test_project.name}/personal/legacy-personal-note"
444+
qualified_permalink = f"personal/{legacy_permalink}"
445+
446+
await write_note(
447+
project=test_project.name,
448+
title="Legacy Personal Note",
449+
directory="personal",
450+
content="Legacy personal workspace content",
451+
)
452+
453+
stored = await entity_repository.get_by_permalink(legacy_permalink)
454+
assert stored is not None
455+
assert stored.permalink == legacy_permalink
456+
457+
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
458+
read_result = await read_note(
459+
f"memory://{qualified_permalink}",
460+
project=test_project.name,
461+
output_format="json",
462+
)
463+
464+
assert isinstance(read_result, dict)
465+
assert read_result["permalink"] == legacy_permalink
466+
assert read_result["content"].strip() == "Legacy personal workspace content"
467+
468+
407469
@pytest.mark.asyncio
408470
async def test_read_note_workspace_qualified_memory_url_uses_complete_permalink(
409471
app,

0 commit comments

Comments
 (0)