Skip to content

Commit db5ef7d

Browse files
feat: enhance move_note tool with cross-project detection and guidance (#161)
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
1 parent f506507 commit db5ef7d

2 files changed

Lines changed: 284 additions & 1 deletion

File tree

src/basic_memory/mcp/tools/move_note.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,151 @@
77

88
from basic_memory.mcp.async_client import client
99
from basic_memory.mcp.server import mcp
10-
from basic_memory.mcp.tools.utils import call_post
10+
from basic_memory.mcp.tools.utils import call_post, call_get
1111
from basic_memory.mcp.project_session import get_active_project
1212
from basic_memory.schemas import EntityResponse
13+
from basic_memory.schemas.project_info import ProjectList
14+
15+
16+
async def _detect_cross_project_move_attempt(
17+
identifier: str, destination_path: str, current_project: str
18+
) -> Optional[str]:
19+
"""Detect potential cross-project move attempts and return guidance.
20+
21+
Args:
22+
identifier: The note identifier being moved
23+
destination_path: The destination path
24+
current_project: The current active project
25+
26+
Returns:
27+
Error message with guidance if cross-project move is detected, None otherwise
28+
"""
29+
try:
30+
# Get list of all available projects to check against
31+
response = await call_get(client, "/projects/projects")
32+
project_list = ProjectList.model_validate(response.json())
33+
project_names = [p.name.lower() for p in project_list.projects]
34+
35+
# Check if destination path contains any project names
36+
dest_lower = destination_path.lower()
37+
path_parts = dest_lower.split("/")
38+
39+
# Look for project names in the destination path
40+
for part in path_parts:
41+
if part in project_names and part != current_project.lower():
42+
# Found a different project name in the path
43+
matching_project = next(p.name for p in project_list.projects if p.name.lower() == part)
44+
return _format_cross_project_error_response(
45+
identifier, destination_path, current_project, matching_project
46+
)
47+
48+
# Check if the destination path looks like it might be trying to reference another project
49+
# (e.g., contains common project-like patterns)
50+
if any(keyword in dest_lower for keyword in ["project", "workspace", "repo"]):
51+
# This might be a cross-project attempt, but we can't be sure
52+
# Return a general guidance message
53+
available_projects = [p.name for p in project_list.projects if p.name != current_project]
54+
if available_projects:
55+
return _format_potential_cross_project_guidance(
56+
identifier, destination_path, current_project, available_projects
57+
)
58+
59+
except Exception as e:
60+
# If we can't detect, don't interfere with normal error handling
61+
logger.debug(f"Could not check for cross-project move: {e}")
62+
return None
63+
64+
return None
65+
66+
67+
def _format_cross_project_error_response(
68+
identifier: str, destination_path: str, current_project: str, target_project: str
69+
) -> str:
70+
"""Format error response for detected cross-project move attempts."""
71+
return dedent(f"""
72+
# Move Failed - Cross-Project Move Not Supported
73+
74+
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
75+
76+
**Current project:** {current_project}
77+
**Target project:** {target_project}
78+
79+
## Cross-project moves are not supported directly
80+
81+
Notes can only be moved within the same project. To move content between projects, use this workflow:
82+
83+
### Recommended approach:
84+
```
85+
# 1. Read the note content from current project
86+
read_note("{identifier}")
87+
88+
# 2. Switch to the target project
89+
switch_project("{target_project}")
90+
91+
# 3. Create the note in the target project
92+
write_note("Note Title", "content from step 1", "target-folder")
93+
94+
# 4. Switch back to original project (optional)
95+
switch_project("{current_project}")
96+
97+
# 5. Delete the original note if desired
98+
delete_note("{identifier}")
99+
```
100+
101+
### Alternative: Stay in current project
102+
If you want to move the note within the **{current_project}** project only:
103+
```
104+
move_note("{identifier}", "new-folder/new-name.md")
105+
```
106+
107+
## Available projects:
108+
Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects.
109+
""").strip()
110+
111+
112+
def _format_potential_cross_project_guidance(
113+
identifier: str, destination_path: str, current_project: str, available_projects: list[str]
114+
) -> str:
115+
"""Format guidance for potentially cross-project moves."""
116+
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
117+
if len(available_projects) > 3:
118+
other_projects += f" (and {len(available_projects) - 3} others)"
119+
120+
return dedent(f"""
121+
# Move Failed - Check Project Context
122+
123+
Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
124+
125+
## If you intended to move within the current project:
126+
The destination path should be relative to the project root:
127+
```
128+
move_note("{identifier}", "folder/filename.md")
129+
```
130+
131+
## If you intended to move to a different project:
132+
Cross-project moves require switching projects first. Available projects: {other_projects}
133+
134+
### To move to another project:
135+
```
136+
# 1. Read the content
137+
read_note("{identifier}")
138+
139+
# 2. Switch to target project
140+
switch_project("target-project-name")
141+
142+
# 3. Create note in target project
143+
write_note("Title", "content", "folder")
144+
145+
# 4. Switch back and delete original if desired
146+
switch_project("{current_project}")
147+
delete_note("{identifier}")
148+
```
149+
150+
### To see all projects:
151+
```
152+
list_projects()
153+
```
154+
""").strip()
13155

14156

15157
def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
@@ -258,6 +400,14 @@ async def move_note(
258400
active_project = get_active_project(project)
259401
project_url = active_project.project_url
260402

403+
# Check for potential cross-project move attempts
404+
cross_project_error = await _detect_cross_project_move_attempt(
405+
identifier, destination_path, active_project.name
406+
)
407+
if cross_project_error:
408+
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
409+
return cross_project_error
410+
261411
try:
262412
# Prepare move request
263413
move_data = {

test-int/mcp/test_move_note_integration.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,136 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app):
507507

508508
read3 = await client.call_tool("read_note", {"identifier": "moved/folder-title-moved.md"})
509509
assert "Move by folder/title" in read3[0].text
510+
511+
512+
@pytest.mark.asyncio
513+
async def test_move_note_cross_project_detection(mcp_server, app):
514+
"""Test cross-project move detection and helpful error messages."""
515+
516+
async with Client(mcp_server) as client:
517+
# Create a test project to simulate cross-project scenario
518+
await client.call_tool(
519+
"create_memory_project",
520+
{
521+
"project_name": "test-project-b",
522+
"project_path": "/tmp/test-project-b",
523+
"set_default": False,
524+
},
525+
)
526+
527+
# Create a note in the default project
528+
await client.call_tool(
529+
"write_note",
530+
{
531+
"title": "Cross Project Test Note",
532+
"folder": "source",
533+
"content": "# Cross Project Test Note\n\nThis note is in the default project.",
534+
"tags": "test,cross-project",
535+
},
536+
)
537+
538+
# Try to move to a path that contains the other project name
539+
move_result = await client.call_tool(
540+
"move_note",
541+
{
542+
"identifier": "Cross Project Test Note",
543+
"destination_path": "test-project-b/moved-note.md",
544+
},
545+
)
546+
547+
# Should detect cross-project attempt and provide helpful guidance
548+
assert len(move_result) == 1
549+
error_message = move_result[0].text
550+
assert "Cross-Project Move Not Supported" in error_message
551+
assert "test-project-b" in error_message
552+
assert "switch_project" in error_message
553+
assert "read_note" in error_message
554+
assert "write_note" in error_message
555+
556+
557+
@pytest.mark.asyncio
558+
async def test_move_note_potential_cross_project_guidance(mcp_server, app):
559+
"""Test guidance for potentially cross-project moves with project-like keywords."""
560+
561+
async with Client(mcp_server) as client:
562+
# Create another test project
563+
await client.call_tool(
564+
"create_memory_project",
565+
{
566+
"project_name": "workspace-docs",
567+
"project_path": "/tmp/workspace-docs",
568+
"set_default": False,
569+
},
570+
)
571+
572+
# Create a note in the default project
573+
await client.call_tool(
574+
"write_note",
575+
{
576+
"title": "Potential Cross Project Note",
577+
"folder": "source",
578+
"content": "# Potential Cross Project Note\n\nThis might be moved cross-project.",
579+
"tags": "test,potential-cross-project",
580+
},
581+
)
582+
583+
# Try to move to a path that contains project-like keywords but not exact project names
584+
move_result = await client.call_tool(
585+
"move_note",
586+
{
587+
"identifier": "Potential Cross Project Note",
588+
"destination_path": "project-archive/moved-note.md",
589+
},
590+
)
591+
592+
# Should provide guidance for potential cross-project moves
593+
assert len(move_result) == 1
594+
error_message = move_result[0].text
595+
assert "Check Project Context" in error_message
596+
assert "workspace-docs" in error_message # Should mention other available projects
597+
assert "list_projects" in error_message
598+
assert "switch_project" in error_message
599+
600+
601+
@pytest.mark.asyncio
602+
async def test_move_note_normal_moves_still_work(mcp_server, app):
603+
"""Test that normal within-project moves still work after cross-project detection."""
604+
605+
async with Client(mcp_server) as client:
606+
# Create a note
607+
await client.call_tool(
608+
"write_note",
609+
{
610+
"title": "Normal Move Note",
611+
"folder": "source",
612+
"content": "# Normal Move Note\n\nThis should move normally.",
613+
"tags": "test,normal-move",
614+
},
615+
)
616+
617+
# Try a normal move that should work
618+
move_result = await client.call_tool(
619+
"move_note",
620+
{
621+
"identifier": "Normal Move Note",
622+
"destination_path": "destination/normal-moved.md",
623+
},
624+
)
625+
626+
# Should work normally
627+
assert len(move_result) == 1
628+
move_text = move_result[0].text
629+
assert "✅ Note moved successfully" in move_text
630+
assert "Normal Move Note" in move_text
631+
assert "destination/normal-moved.md" in move_text
632+
633+
# Verify the note can be read from its new location
634+
read_result = await client.call_tool(
635+
"read_note",
636+
{
637+
"identifier": "destination/normal-moved.md",
638+
},
639+
)
640+
641+
content = read_result[0].text
642+
assert "This should move normally" in content

0 commit comments

Comments
 (0)