Skip to content
Merged
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
49 changes: 35 additions & 14 deletions src/basic_memory/mcp/clients/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
DirectoryDeleteResult,
)
from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse
from basic_memory.schemas.v2.entity import EntityResolveResponse


class KnowledgeClient:
Expand Down Expand Up @@ -348,19 +349,13 @@ async def get_orphans(self) -> list[GraphNode]:

# --- Resolution ---

async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
"""Resolve a string identifier to an entity external_id.

Args:
identifier: The identifier to resolve (permalink, title, or path)
strict: If True, require exact matching (no fuzzy fallback)

Returns:
The resolved entity external_id (UUID)

Raises:
ToolError: If the identifier cannot be resolved
"""
async def _resolve_entity_data(
self,
identifier: str,
*,
strict: bool = False,
) -> dict[str, Any]:
"""Request the complete entity-resolution payload."""
from basic_memory.mcp.tools.utils import call_post

with logfire.span(
Expand All @@ -376,5 +371,31 @@ async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
operation="resolve_entity",
path_template="/v2/projects/{project_id}/knowledge/resolve",
)
data = response.json()
data: dict[str, Any] = response.json()
return data

async def resolve_entity_response(
self,
identifier: str,
*,
strict: bool = False,
) -> EntityResolveResponse:
"""Resolve an identifier while preserving owning-project metadata.

Args:
identifier: The identifier to resolve (permalink, title, or path)
strict: If True, require exact matching (no fuzzy fallback)

Returns:
The complete validated resolution response

Raises:
ToolError: If the identifier cannot be resolved
"""
data = await self._resolve_entity_data(identifier, strict=strict)
return EntityResolveResponse.model_validate(data)

async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
"""Resolve a string identifier to an entity external_id."""
data = await self._resolve_entity_data(identifier, strict=strict)
return data["external_id"]
31 changes: 31 additions & 0 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ class WorkspaceProjectLookupMiss(ValueError):
"""


class UnresolvedProjectRouteError(ValueError):
"""A mutating memory URL named a project prefix that could not be resolved."""

def __init__(self, identifier: str, project_prefix: str):
self.identifier = identifier
self.project_prefix = project_prefix
super().__init__(
f"Memory URL project route '{project_prefix}' could not be resolved; "
"refusing to treat the URL as a path in the active project."
)


@dataclass(frozen=True)
class WorkspaceProjectEntry:
"""A cloud project resolved together with the workspace that owns it."""
Expand Down Expand Up @@ -1278,11 +1290,22 @@ async def resolve_project_and_path(
project: Optional[str] = None,
context: Optional[Context] = None,
headers: HeaderTypes | None = None,
*,
strict_project_routing: bool = False,
) -> tuple[ProjectItem, str, bool]:
"""Resolve project and normalized path for memory:// identifiers.

Args:
strict_project_routing: Reject a memory URL whose leading project-like
segment cannot be resolved. Mutating tools use this to prevent a
failed route from falling back to the active project.

Returns:
Tuple of (active_project, normalized_path, is_memory_url)

Raises:
UnresolvedProjectRouteError: If strict routing is enabled and the
memory URL's leading project segment does not resolve.
"""
is_memory_url = identifier.strip().startswith("memory://")
config = ConfigManager().config
Expand Down Expand Up @@ -1380,6 +1403,14 @@ async def resolve_project_and_path(
except ToolError as exc:
if "project not found" not in str(exc).lower():
raise
if strict_project_routing:
# Trigger: a mutating tool supplied a memory URL whose leading
# project-like segment did not resolve.
# Why: falling back would reinterpret the full route as a path
# in the active project, allowing append/prepend to create a
# phantom note in the wrong project (#1066).
# Outcome: stop before entity resolution or file creation.
raise UnresolvedProjectRouteError(identifier, project_prefix) from exc
else:
resolved_project = await resolve_project_parameter(project_prefix, context=context)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
Expand Down
107 changes: 100 additions & 7 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from basic_memory.config import ConfigManager
from basic_memory.ignore_utils import IGNORED_PATH_REJECTION_DETAIL
from basic_memory.mcp.project_context import (
UnresolvedProjectRouteError,
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
get_project_client,
Expand Down Expand Up @@ -193,6 +194,38 @@ def _format_ambiguous_workspace_identifier_response(
- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`"""


def _format_unresolved_project_route_response(
*,
error: UnresolvedProjectRouteError,
active_project: str,
) -> str:
"""Format a safe stop when a mutating memory URL cannot be routed."""
return f"""# Edit Failed - Unresolved Project Route

The memory URL `{error.identifier}` starts with the project route `{error.project_prefix}`, but that project could not be resolved.

No note was edited or created. Basic Memory did not fall back to the active project `{active_project}`.

## How to retry
1. Use `list_memory_projects()` to confirm the workspace, project, and project ID.
2. Correct the `memory://` URL so its project route exists, or pass the note path with an explicit `project` or `project_id`.
3. If `{error.project_prefix}` is a directory in `{active_project}`, remove the `memory://` prefix and retry with `project="{active_project}"`."""


def _format_cross_project_entity_response(
*,
identifier: str,
active_project: str,
target_project_id: str,
) -> str:
"""Format a safe stop when resolution finds a note in another project."""
return f"""# Edit Failed - Note Not Found In This Project

The identifier `{identifier}` resolved to a note outside the selected project `{active_project}`, so no changes were made.

Retry with `project_id="{target_project_id}"`, or use `list_memory_projects()` to confirm the intended project before editing."""


def _format_error_response(
error_message: str,
operation: str,
Expand Down Expand Up @@ -554,23 +587,64 @@ async def edit_note(

# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
_, entity_identifier, _ = await resolve_project_and_path(
client,
identifier,
active_project.name,
context,
)
unresolved_project_route: UnresolvedProjectRouteError | None = None
try:
_, entity_identifier, _ = await resolve_project_and_path(
client,
identifier,
active_project.name,
context,
strict_project_routing=True,
)
except UnresolvedProjectRouteError as route_error:
# Trigger: a memory URL's first segment is not a project, which
# can also describe a valid active-project path such as
# memory://src/existing-note.
# Why: existing indexed notes must remain editable, but a miss
# must never reach append/prepend auto-create.
# Outcome: resolve once with the read-compatible fallback, then
# raise the saved route error if normal recovery still misses.
unresolved_project_route = route_error
_, entity_identifier, _ = await resolve_project_and_path(
client,
identifier,
active_project.name,
context,
)

file_created = False
entity_id = ""
result: EntityResponse | None = None

# Try to resolve the entity; for append/prepend, create it if not found
try:
entity_id = await knowledge_client.resolve_entity(
resolved_entity = await knowledge_client.resolve_entity_response(
entity_identifier,
strict=True,
)
if resolved_entity.project_external_id != active_project.external_id:
# Trigger: the link resolver found a note owned by another project.
# Why: patching through the active project's endpoint would leak
# an internal entity ID in a misleading 404 and cannot succeed.
# Outcome: stop before mutation and provide the owning project ID.
if output_format == "json":
return {
"title": None,
"permalink": None,
"file_path": None,
"checksum": None,
"operation": operation,
"fileCreated": False,
"error": "CROSS_PROJECT_ENTITY",
"project": active_project.name,
"targetProjectId": resolved_entity.project_external_id,
}
return _format_cross_project_entity_response(
identifier=identifier,
active_project=active_project.name,
target_project_id=resolved_entity.project_external_id,
)
entity_id = resolved_entity.external_id
except Exception as resolve_error:
error_msg = str(resolve_error).lower()
is_not_found = "entity not found" in error_msg or "not found" in error_msg
Expand All @@ -587,6 +661,8 @@ async def edit_note(

if recovered_entity_id is not None:
entity_id = recovered_entity_id
elif is_not_found and unresolved_project_route is not None:
raise unresolved_project_route
elif is_not_found and operation in ("append", "prepend"):
# Trigger: entity does not exist yet (on disk or in the index)
# Why: append/prepend can meaningfully create a new note from the
Expand Down Expand Up @@ -741,6 +817,23 @@ async def edit_note(

except Exception as e:
logger.error(f"Error editing note: {e}")
if isinstance(e, UnresolvedProjectRouteError):
if output_format == "json":
return {
"title": None,
"permalink": None,
"file_path": None,
"checksum": None,
"operation": operation,
"fileCreated": False,
"error": "UNRESOLVED_PROJECT_ROUTE",
"project": active_project.name,
"projectRoute": e.project_prefix,
}
return _format_unresolved_project_route_response(
error=e,
active_project=active_project.name,
)
if output_format == "json":
return {
"title": None,
Expand Down
27 changes: 27 additions & 0 deletions tests/mcp/clients/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,33 @@ async def mock_call_post(client, url, **kwargs):
result = await client.resolve_entity("my-note")
assert result == "entity-uuid-123"

@pytest.mark.asyncio
async def test_resolve_entity_response_preserves_project_metadata(self, monkeypatch):
"""Complete resolution responses retain the owning project external ID."""
mock_response = MagicMock()
mock_response.json.return_value = {
"external_id": "entity-uuid-123",
"entity_id": 42,
"project_external_id": "project-uuid-456",
"permalink": "other-project/notes/my-note",
"file_path": "notes/My Note.md",
"title": "My Note",
"resolution_method": "permalink",
}

async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/resolve" in url
assert kwargs["json"] == {"identifier": "my-note", "strict": True}
return mock_response

monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)

client = KnowledgeClient(MagicMock(), "proj-123")
result = await client.resolve_entity_response("my-note", strict=True)

assert result.external_id == "entity-uuid-123"
assert result.project_external_id == "project-uuid-456"

@pytest.mark.asyncio
async def test_index_file(self, monkeypatch):
"""Test index_file posts the file path to the index-file endpoint."""
Expand Down
Loading
Loading