Skip to content

Commit 1a6a655

Browse files
phernandezclaude
andauthored
fix(mcp): add project detection from memory:// URLs in edit_note and delete_note (#668)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c8b0044 commit 1a6a655

4 files changed

Lines changed: 143 additions & 2 deletions

File tree

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from fastmcp import Context
66
from mcp.server.fastmcp.exceptions import ToolError
77

8-
from basic_memory.mcp.project_context import get_project_client
8+
from basic_memory.config import ConfigManager
9+
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
910
from basic_memory.mcp.server import mcp
1011

1112

@@ -222,6 +223,16 @@ async def delete_note(
222223
with suggestions for finding the correct identifier, including search
223224
commands and alternative formats to try.
224225
"""
226+
# Detect project from memory URL prefix before routing
227+
# Trigger: identifier starts with memory:// and no explicit project was provided
228+
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
229+
# where "research" is a directory, not a project name
230+
# Outcome: project is set from the URL prefix, routing goes to the correct project
231+
if project is None and identifier.strip().startswith("memory://"):
232+
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
233+
if detected:
234+
project = detected
235+
225236
async with get_project_client(project, workspace, context) as (client, active_project):
226237
logger.debug(
227238
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
from loguru import logger
66
from fastmcp import Context
77

8-
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
8+
from basic_memory.config import ConfigManager
9+
from basic_memory.mcp.project_context import (
10+
detect_project_from_url_prefix,
11+
get_project_client,
12+
add_project_metadata,
13+
)
914
from basic_memory.mcp.server import mcp
1015
from basic_memory.schemas.base import Entity
1116
from basic_memory.schemas.response import EntityResponse
@@ -255,6 +260,16 @@ async def edit_note(
255260
# Resolve effective default: allow MCP clients to send null for optional int field
256261
effective_replacements = expected_replacements if expected_replacements is not None else 1
257262

263+
# Detect project from memory URL prefix before routing
264+
# Trigger: identifier starts with memory:// and no explicit project was provided
265+
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
266+
# where "research" is a directory, not a project name
267+
# Outcome: project is set from the URL prefix, routing goes to the correct project
268+
if project is None and identifier.strip().startswith("memory://"):
269+
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
270+
if detected:
271+
project = detected
272+
258273
async with get_project_client(project, workspace, context) as (client, active_project):
259274
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
260275

tests/mcp/test_tool_delete_note.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for delete_note MCP tool."""
22

3+
from unittest.mock import patch
4+
35
import pytest
46

57
from basic_memory.mcp.tools.delete_note import delete_note, _format_delete_error_response
@@ -120,3 +122,56 @@ async def test_delete_note_rejects_fuzzy_match(client, test_project):
120122
# Verify the existing note was NOT deleted
121123
content = await read_note("Delete Target Note", project=test_project.name)
122124
assert "Should not be deleted" in content
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_delete_note_detects_project_from_memory_url(client, test_project):
129+
"""delete_note should detect project from memory:// URL prefix when project=None."""
130+
# Create a note to delete
131+
await write_note(
132+
project=test_project.name,
133+
title="Delete URL Note",
134+
directory="test",
135+
content="# Delete URL Note\nContent to delete.",
136+
)
137+
138+
# Delete using memory:// URL with project=None — should auto-detect project
139+
# The note may or may not be found (depends on URL resolution), but the key
140+
# assertion is that routing goes to the correct project
141+
result = await delete_note(
142+
identifier=f"memory://{test_project.name}/test/delete-url-note",
143+
project=None,
144+
)
145+
146+
# Result is True (deleted) or False (not found by that URL) — either is acceptable.
147+
# The important thing is it didn't error and routed to the correct project.
148+
assert isinstance(result, bool)
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_delete_note_skips_detection_for_plain_path(client, test_project):
153+
"""delete_note should NOT call detect_project_from_url_prefix for plain path identifiers.
154+
155+
A plain path like 'research/note' should not be misrouted to a project
156+
named 'research' — the 'research' segment is a directory, not a project.
157+
"""
158+
with patch("basic_memory.mcp.tools.delete_note.detect_project_from_url_prefix") as mock_detect:
159+
# Use a plain path (no memory:// prefix) — detection should not be called
160+
await delete_note(
161+
identifier="test/nonexistent-note",
162+
project=None,
163+
)
164+
165+
mock_detect.assert_not_called()
166+
167+
168+
@pytest.mark.asyncio
169+
async def test_delete_note_skips_detection_when_project_provided(client, test_project):
170+
"""delete_note should skip URL detection when project is explicitly provided."""
171+
with patch("basic_memory.mcp.tools.delete_note.detect_project_from_url_prefix") as mock_detect:
172+
await delete_note(
173+
identifier=f"memory://{test_project.name}/test/some-note",
174+
project=test_project.name,
175+
)
176+
177+
mock_detect.assert_not_called()

tests/mcp/test_tool_edit_note.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for the edit_note MCP tool."""
22

3+
from unittest.mock import patch
4+
35
import pytest
46

57
from basic_memory.mcp.tools.edit_note import edit_note
@@ -769,3 +771,61 @@ async def test_edit_note_insert_before_section_not_found(client, test_project):
769771

770772
assert isinstance(result, str)
771773
assert "# Edit Failed" in result
774+
775+
776+
@pytest.mark.asyncio
777+
async def test_edit_note_detects_project_from_memory_url(client, test_project):
778+
"""edit_note should detect project from memory:// URL prefix when project=None."""
779+
# Create a note first
780+
await write_note(
781+
project=test_project.name,
782+
title="URL Detection Note",
783+
directory="test",
784+
content="# URL Detection Note\nOriginal content.",
785+
)
786+
787+
# Edit using memory:// URL with project=None — should auto-detect project
788+
# The memory URL uses the permalink (which includes project prefix)
789+
result = await edit_note(
790+
identifier=f"memory://{test_project.name}/test/url-detection-note",
791+
operation="append",
792+
content="\nAppended via memory URL.",
793+
project=None,
794+
)
795+
796+
assert isinstance(result, str)
797+
# Should route to the correct project and succeed (either edit or create)
798+
assert f"project: {test_project.name}" in result
799+
800+
801+
@pytest.mark.asyncio
802+
async def test_edit_note_skips_detection_for_plain_path(client, test_project):
803+
"""edit_note should NOT call detect_project_from_url_prefix for plain path identifiers.
804+
805+
A plain path like 'research/note' should not be misrouted to a project
806+
named 'research' — the 'research' segment is a directory, not a project.
807+
"""
808+
with patch("basic_memory.mcp.tools.edit_note.detect_project_from_url_prefix") as mock_detect:
809+
# Use a plain path (no memory:// prefix) — detection should not be called
810+
await edit_note(
811+
identifier="test/some-note",
812+
operation="append",
813+
content="content",
814+
project=None,
815+
)
816+
817+
mock_detect.assert_not_called()
818+
819+
820+
@pytest.mark.asyncio
821+
async def test_edit_note_skips_detection_when_project_provided(client, test_project):
822+
"""edit_note should skip URL detection when project is explicitly provided."""
823+
with patch("basic_memory.mcp.tools.edit_note.detect_project_from_url_prefix") as mock_detect:
824+
await edit_note(
825+
identifier=f"memory://{test_project.name}/test/some-note",
826+
operation="append",
827+
content="content",
828+
project=test_project.name,
829+
)
830+
831+
mock_detect.assert_not_called()

0 commit comments

Comments
 (0)