Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/basic_memory/mcp/tools/delete_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError

from basic_memory.mcp.project_context import get_project_client
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
from basic_memory.mcp.server import mcp


Expand Down Expand Up @@ -222,6 +223,16 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
# Detect project from memory URL prefix before routing
# Trigger: identifier starts with memory:// and no explicit project was provided
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
# where "research" is a directory, not a project name
# Outcome: project is set from the URL prefix, routing goes to the correct project
if project is None and identifier.strip().startswith("memory://"):
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
if detected:
project = detected

async with get_project_client(project, workspace, context) as (client, active_project):
logger.debug(
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
Expand Down
17 changes: 16 additions & 1 deletion src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
from loguru import logger
from fastmcp import Context

from basic_memory.mcp.project_context import get_project_client, add_project_metadata
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
get_project_client,
add_project_metadata,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import Entity
from basic_memory.schemas.response import EntityResponse
Expand Down Expand Up @@ -255,6 +260,16 @@ async def edit_note(
# Resolve effective default: allow MCP clients to send null for optional int field
effective_replacements = expected_replacements if expected_replacements is not None else 1

# Detect project from memory URL prefix before routing
# Trigger: identifier starts with memory:// and no explicit project was provided
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
# where "research" is a directory, not a project name
# Outcome: project is set from the URL prefix, routing goes to the correct project
if project is None and identifier.strip().startswith("memory://"):
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
if detected:
project = detected

async with get_project_client(project, workspace, context) as (client, active_project):
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)

Expand Down
55 changes: 55 additions & 0 deletions tests/mcp/test_tool_delete_note.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for delete_note MCP tool."""

from unittest.mock import patch

import pytest

from basic_memory.mcp.tools.delete_note import delete_note, _format_delete_error_response
Expand Down Expand Up @@ -120,3 +122,56 @@ async def test_delete_note_rejects_fuzzy_match(client, test_project):
# Verify the existing note was NOT deleted
content = await read_note("Delete Target Note", project=test_project.name)
assert "Should not be deleted" in content


@pytest.mark.asyncio
async def test_delete_note_detects_project_from_memory_url(client, test_project):
"""delete_note should detect project from memory:// URL prefix when project=None."""
# Create a note to delete
await write_note(
project=test_project.name,
title="Delete URL Note",
directory="test",
content="# Delete URL Note\nContent to delete.",
)

# Delete using memory:// URL with project=None — should auto-detect project
# The note may or may not be found (depends on URL resolution), but the key
# assertion is that routing goes to the correct project
result = await delete_note(
identifier=f"memory://{test_project.name}/test/delete-url-note",
project=None,
)

# Result is True (deleted) or False (not found by that URL) — either is acceptable.
# The important thing is it didn't error and routed to the correct project.
assert isinstance(result, bool)


@pytest.mark.asyncio
async def test_delete_note_skips_detection_for_plain_path(client, test_project):
"""delete_note should NOT call detect_project_from_url_prefix for plain path identifiers.

A plain path like 'research/note' should not be misrouted to a project
named 'research' — the 'research' segment is a directory, not a project.
"""
with patch("basic_memory.mcp.tools.delete_note.detect_project_from_url_prefix") as mock_detect:
# Use a plain path (no memory:// prefix) — detection should not be called
await delete_note(
identifier="test/nonexistent-note",
project=None,
)

mock_detect.assert_not_called()


@pytest.mark.asyncio
async def test_delete_note_skips_detection_when_project_provided(client, test_project):
"""delete_note should skip URL detection when project is explicitly provided."""
with patch("basic_memory.mcp.tools.delete_note.detect_project_from_url_prefix") as mock_detect:
await delete_note(
identifier=f"memory://{test_project.name}/test/some-note",
project=test_project.name,
)

mock_detect.assert_not_called()
59 changes: 59 additions & 0 deletions tests/mcp/test_tool_edit_note.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the edit_note MCP tool."""

from unittest.mock import patch

import pytest

Expand Down Expand Up @@ -770,3 +771,61 @@ async def test_edit_note_insert_before_section_not_found(client, test_project):

assert isinstance(result, str)
assert "# Edit Failed" in result


@pytest.mark.asyncio
async def test_edit_note_detects_project_from_memory_url(client, test_project):
"""edit_note should detect project from memory:// URL prefix when project=None."""
# Create a note first
await write_note(
project=test_project.name,
title="URL Detection Note",
directory="test",
content="# URL Detection Note\nOriginal content.",
)

# Edit using memory:// URL with project=None — should auto-detect project
# The memory URL uses the permalink (which includes project prefix)
result = await edit_note(
identifier=f"memory://{test_project.name}/test/url-detection-note",
operation="append",
content="\nAppended via memory URL.",
project=None,
)

assert isinstance(result, str)
# Should route to the correct project and succeed (either edit or create)
assert f"project: {test_project.name}" in result


@pytest.mark.asyncio
async def test_edit_note_skips_detection_for_plain_path(client, test_project):
"""edit_note should NOT call detect_project_from_url_prefix for plain path identifiers.

A plain path like 'research/note' should not be misrouted to a project
named 'research' — the 'research' segment is a directory, not a project.
"""
with patch("basic_memory.mcp.tools.edit_note.detect_project_from_url_prefix") as mock_detect:
# Use a plain path (no memory:// prefix) — detection should not be called
await edit_note(
identifier="test/some-note",
operation="append",
content="content",
project=None,
)

mock_detect.assert_not_called()


@pytest.mark.asyncio
async def test_edit_note_skips_detection_when_project_provided(client, test_project):
"""edit_note should skip URL detection when project is explicitly provided."""
with patch("basic_memory.mcp.tools.edit_note.detect_project_from_url_prefix") as mock_detect:
await edit_note(
identifier=f"memory://{test_project.name}/test/some-note",
operation="append",
content="content",
project=test_project.name,
)

mock_detect.assert_not_called()
Loading