From 8ea66d8d66a2cf6b0bdeb98cc705c37be869747a Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 29 Oct 2026 12:49:11 -0500 Subject: [PATCH 1/2] refactor(mcp): remove read_note, edit_note, workspaces and chatgpt tool modules Basic Memory 1.0 publishes only the bm_* tools, and Cloud no longer imports these modules (basic-memory-cloud#2857). Delete them with the code only they reached: the link_resolver workspace-prefix detection, routes_to_default_project and add_project_metadata, KnowledgeClient.place_note, and the strict-routing options of resolve_project_and_path with UnresolvedProjectRouteError. The read_note title-lookup paging loop goes with read_note. Port their tests to bm_read, bm_edit, bm_search and bm_context, and drop the ones that covered legacy-only behavior (title-search fallback, auto-create, disk recovery, legacy output shapes). Telemetry tests now use logfire's capfire instead of patching logfire.span. Closes #1958 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CMYVL1x91B8kJxy7nn2GU8 Signed-off-by: phernandez --- src/basic_memory/mcp/clients/knowledge.py | 39 - src/basic_memory/mcp/project_context.py | 37 +- .../mcp/project_context_identifiers.py | 39 - src/basic_memory/mcp/tools/__init__.py | 7 - src/basic_memory/mcp/tools/chatgpt_tools.py | 277 --- src/basic_memory/mcp/tools/edit_note.py | 1160 --------- src/basic_memory/mcp/tools/read_note.py | 644 ----- src/basic_memory/mcp/tools/workspaces.py | 72 - src/basic_memory/services/link_resolver.py | 50 +- test-int/mcp/test_adapter_project_routing.py | 33 - .../mcp/test_chatgpt_tools_integration.py | 492 ---- tests/implementation_mcp.py | 5 - tests/mcp/test_line_scanning.py | 48 +- tests/mcp/test_note_reads.py | 170 ++ tests/mcp/test_project_context.py | 18 +- tests/mcp/test_project_context_scoped_key.py | 118 - tests/mcp/test_read_note_request_counts.py | 507 ---- tests/mcp/test_refusals.py | 9 + tests/mcp/test_remote_path_validation.py | 27 +- tests/mcp/test_required_project.py | 13 - tests/mcp/test_tool_bm_edit.py | 317 +++ tests/mcp/test_tool_bm_read.py | 217 ++ tests/mcp/test_tool_contracts.py | 42 - tests/mcp/test_tool_edit_note.py | 2109 ----------------- tests/mcp/test_tool_guidance.py | 16 +- tests/mcp/test_tool_json_output_modes.py | 76 - tests/mcp/test_tool_read_note.py | 1559 ------------ tests/mcp/test_tool_telemetry.py | 241 +- tests/mcp/test_tool_workspace_management.py | 255 -- tests/mcp/test_unknown_project_routing.py | 6 +- tests/mcp/test_wiki_after_api_writes.py | 6 +- .../test_workspace_permalink_resolution.py | 15 +- tests/mcp/test_workspace_provider.py | 91 + tests/mcp/tools/test_chatgpt_tools.py | 413 ---- tests/services/test_link_resolver.py | 185 -- 35 files changed, 930 insertions(+), 8383 deletions(-) delete mode 100644 src/basic_memory/mcp/tools/chatgpt_tools.py delete mode 100644 src/basic_memory/mcp/tools/edit_note.py delete mode 100644 src/basic_memory/mcp/tools/read_note.py delete mode 100644 src/basic_memory/mcp/tools/workspaces.py delete mode 100644 test-int/mcp/test_chatgpt_tools_integration.py create mode 100644 tests/mcp/test_note_reads.py delete mode 100644 tests/mcp/test_read_note_request_counts.py delete mode 100644 tests/mcp/test_tool_edit_note.py delete mode 100644 tests/mcp/test_tool_read_note.py delete mode 100644 tests/mcp/test_tool_workspace_management.py create mode 100644 tests/mcp/test_workspace_provider.py delete mode 100644 tests/mcp/tools/test_chatgpt_tools.py diff --git a/src/basic_memory/mcp/clients/knowledge.py b/src/basic_memory/mcp/clients/knowledge.py index 32bf08f10..f3707ab0c 100644 --- a/src/basic_memory/mcp/clients/knowledge.py +++ b/src/basic_memory/mcp/clients/knowledge.py @@ -8,7 +8,6 @@ from typing import Any from httpx import AsyncClient, HTTPStatusError -from pydantic import TypeAdapter import logfire @@ -32,12 +31,6 @@ MAX_ACCEPTED_CONTENT_BATCH, AcceptedNoteContentBatchResponse, ) -from basic_memory.schemas.v2.note_placement import ( - AmbiguousNotePlacementResponse, - NoteCreatePlacementResponse, - NotePlacementResponse, - OutsideProjectPlacementResponse, -) from basic_memory.schemas.base import Entity from basic_memory.schemas.v2.note_write import ( WriteNoteRequest, @@ -46,11 +39,6 @@ ) -_NOTE_PLACEMENT_ADAPTER: TypeAdapter[ - NoteCreatePlacementResponse | AmbiguousNotePlacementResponse | OutsideProjectPlacementResponse -] = TypeAdapter(NotePlacementResponse) - - class KnowledgeClient: """Typed client for knowledge graph entity operations. @@ -486,33 +474,6 @@ async def get_orphans(self) -> list[GraphNode]: ) return OrphanEntitiesResponse.model_validate(response.json()).entities - # --- Placement --- - - async def place_note( - self, identifier: str - ) -> ( - NoteCreatePlacementResponse - | AmbiguousNotePlacementResponse - | OutsideProjectPlacementResponse - ): - """Ask where a note that resolves under none of its readings would be created.""" - from basic_memory.mcp.tools.utils import call_query - - with logfire.span( - "mcp.client.knowledge.place_note", - client_name="knowledge", - operation="place_note", - ): - response = await call_query( - self.http_client, - f"{self._base_path}/placement", - json={"identifier": identifier}, - client_name="knowledge", - operation="place_note", - path_template="/v2/projects/{project_id}/knowledge/placement", - ) - return _NOTE_PLACEMENT_ADAPTER.validate_python(response.json()) - # --- Resolution --- async def _resolve_entity_data( diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index e268e3698..3ba1188b7 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -54,10 +54,7 @@ workspace_permalink_context, ) from basic_memory.mcp.project_context_identifiers import ( - UnresolvedProjectRouteError, WorkspaceMemoryUrlResolution, - add_project_metadata as _add_project_metadata, - routes_to_default_project as _routes_to_default_project, canonical_memory_path_for_active_route as _canonical_memory_path_for_active_route, canonical_memory_path_for_workspace as _canonical_memory_path_for_workspace, canonicalize_project_name as _canonicalize_project_name, @@ -94,8 +91,6 @@ # Keep the original module's helper surface intact for callers and tests while # the implementations live in focused, dependency-light modules. -add_project_metadata = _add_project_metadata -routes_to_default_project = _routes_to_default_project _format_qualified_choices = _format_qualified_choices_impl _match_workspace_identifier = _match_workspace_identifier_impl _split_qualified_project_identifier = _split_qualified_project_identifier_impl @@ -795,31 +790,11 @@ async def resolve_project_and_path( project: Optional[str] = None, context: Optional[Context] = None, headers: HeaderTypes | None = None, - *, - strict_project_routing: bool = False, - allow_missing_project_fallback: bool = False, - cache_resolved_project: bool = True, ) -> 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. - allow_missing_project_fallback: When strict routing is enabled, still - allow a genuinely missing project prefix to be treated as an active- - project path. This is safe only for mutations that require an existing - target and cannot create content. - cache_resolved_project: Persist a project resolved from the memory URL in - MCP context. Set this to false for validation-only routing that may - reject a resolved cross-project source. - 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 @@ -957,18 +932,9 @@ async def resolve_project_and_path( "does not have access to this project" in error_message and cached_project is not None and _project_matches_identifier(cached_project, project) - and not strict_project_routing ) if not project_route_missing and not project_route_hidden_by_scope: raise - if strict_project_routing and not ( - project_route_missing and allow_missing_project_fallback - ): - # Mutations that can create content must not reinterpret a - # missing project route as an active-project path (#1066). - # Existing-target mutations may opt into that legacy path - # fallback, while scope-hidden routes always fail above. - 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( @@ -985,8 +951,7 @@ async def resolve_project_and_path( path=resolved.path, is_default=resolved.is_default, ) - if cache_resolved_project: - await _set_cached_active_project(context, active_project) + await _set_cached_active_project(context, active_project) resolved_path = _canonical_memory_path_for_active_route( active_project, diff --git a/src/basic_memory/mcp/project_context_identifiers.py b/src/basic_memory/mcp/project_context_identifiers.py index eea953b9f..261bed008 100644 --- a/src/basic_memory/mcp/project_context_identifiers.py +++ b/src/basic_memory/mcp/project_context_identifiers.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from dataclasses import dataclass from typing import Iterable, Optional @@ -19,18 +18,6 @@ from basic_memory.workspace_context import current_workspace_permalink_context -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 WorkspaceMemoryUrlResolution: """Resolved workspace/project route for a workspace-qualified memory URL.""" @@ -263,32 +250,6 @@ def split_project_prefix(path: str) -> tuple[Optional[str], str]: return project_prefix, remainder -def routes_to_default_project(project: Optional[str], project_id: Optional[str]) -> bool: - """Return whether a call with these arguments is served by the default project. - - MCP requests carry no session state, so a call that names no project goes to the - default project, unless the server is constrained to one project - (BASIC_MEMORY_MCP_PROJECT), which then serves every call. - """ - if project is not None or project_id is not None: - return False - return not os.environ.get("BASIC_MEMORY_MCP_PROJECT") - - -def add_project_metadata(result: str, project_name: str, *, used_default: bool) -> str: - """Name the project a write landed in. - - When the default project served a call that named none, say so, so a caller that - meant another project does not mistake where the write went. - """ - if not used_default: - return f"{result}\n\n[Project: '{project_name}']" - return ( - f"{result}\n\n[Project: '{project_name}' (no project was given, so this used " - "the default project; pass project to target another)]" - ) - - def detect_project_from_url_prefix( identifier: str, config: BasicMemoryConfig, diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 704022574..2999d211c 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -6,14 +6,7 @@ """ # Shared implementations used by the CLI and the bm_* tools; these do not register MCP tools. -from basic_memory.mcp.tools.read_note import read_note as read_note - from basic_memory.mcp.tools.search import search_notes as search_notes -from basic_memory.mcp.tools.edit_note import edit_note as edit_note -from basic_memory.mcp.tools.workspaces import list_workspaces as list_workspaces - -# ChatGPT adapter implementations; Cloud owns their registration. -from basic_memory.mcp.tools.chatgpt_tools import search as search, fetch as fetch # Shared POSIX implementations used by the shell commands. from basic_memory.mcp.tools.posix_tools import ( diff --git a/src/basic_memory/mcp/tools/chatgpt_tools.py b/src/basic_memory/mcp/tools/chatgpt_tools.py deleted file mode 100644 index ba4154936..000000000 --- a/src/basic_memory/mcp/tools/chatgpt_tools.py +++ /dev/null @@ -1,277 +0,0 @@ -"""ChatGPT-compatible MCP tools for Basic Memory. - -These adapters expose Basic Memory's search/fetch functionality using the exact -tool names and response structure OpenAI's MCP clients expect: each call returns -a list containing a single `{"type": "text", "text": "{...json...}"}` item. -""" - -import json -from typing import Any, Dict, List, Optional, cast - -from fastmcp import Context -from loguru import logger - -from basic_memory.mcp.client_info import is_openai_mcp_client -from basic_memory.config import ConfigManager -from basic_memory.mcp.project_context import ( - detect_project_from_identifier_prefix, - resolve_project_parameter, -) -from basic_memory.mcp.tools.read import bm_read -from basic_memory.mcp.refusals import Refusal, refusal -from basic_memory.mcp.tools.ranked_search import bm_search -from basic_memory.schemas.search import SearchResponse, SearchResult - - -_UNSUPPORTED_CLIENT_ERROR = "Unsupported MCP client" - - -def _text_content(payload: dict[str, Any]) -> List[Dict[str, Any]]: - return [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] - - -def _unsupported_search_client_response() -> List[Dict[str, Any]]: - return _text_content( - { - "results": [], - "error": _UNSUPPORTED_CLIENT_ERROR, - "error_message": ( - "The search compatibility tool is only available to OpenAI MCP clients. " - "Use bm_search instead." - ), - } - ) - - -def _unsupported_fetch_client_response(identifier: str) -> List[Dict[str, Any]]: - return _text_content( - { - "id": identifier, - "title": "Unsupported MCP Client", - "text": ( - "The fetch compatibility tool is only available to OpenAI MCP clients. " - "Use bm_read instead." - ), - "url": identifier, - "metadata": {"error": _UNSUPPORTED_CLIENT_ERROR}, - } - ) - - -def _identifier_for_bm_read(identifier: str) -> str: - """Convert ChatGPT result ids into routable Basic Memory identifiers.""" - stripped = identifier.strip() - if stripped.startswith("memory://") or "/" not in stripped: - return identifier - return f"memory://{stripped}" - - -def _format_search_results_for_chatgpt( - results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any], -) -> List[Dict[str, Any]]: - """Format search results according to ChatGPT's expected schema. - - Returns a list of result objects with id, title, and url fields. - """ - if isinstance(results, SearchResponse): - raw_results: list[SearchResult | dict[str, Any]] = list(results.results) - elif isinstance(results, dict): - nested_results = results.get("results") - raw_results = ( - cast(list[SearchResult | dict[str, Any]], nested_results) - if isinstance(nested_results, list) - else [] - ) - else: - raw_results = results - - formatted_results = [] - - for result in raw_results: - if isinstance(result, SearchResult): - title = result.title - permalink = result.permalink - elif isinstance(result, dict): - title = result.get("title") - permalink = result.get("permalink") - else: - raise TypeError(f"Unexpected result type: {type(result).__name__}") - - formatted_result = { - "id": permalink or f"doc-{len(formatted_results)}", - "title": title if isinstance(title, str) and title.strip() else "Untitled", - "url": permalink or "", - } - formatted_results.append(formatted_result) - - return formatted_results - - -def _format_document_for_chatgpt( - content: str, identifier: str, title: Optional[str] = None -) -> Dict[str, Any]: - """Format document content according to ChatGPT's expected schema. - - Returns a document object with id, title, text, url, and metadata fields. - """ - # Extract title from markdown content if not provided - if not title and isinstance(content, str): - lines = content.split("\n") - if lines and lines[0].startswith("# "): - title = lines[0][2:].strip() - else: - title = identifier.split("/")[-1].replace("-", " ").title() - - # Ensure title is never None - if not title: - title = "Untitled Document" - - return { - "id": identifier, - "title": title or "Untitled Document", - "text": content, - "url": identifier, - "metadata": {"format": "markdown"}, - } - - -async def search( - query: str, - context: Context | None = None, -) -> List[Dict[str, Any]]: - """ChatGPT/OpenAI MCP search adapter returning a single text content item. - - Args: - query: Search query (full-text syntax supported by `bm_search`) - context: Optional FastMCP context passed through for auth/session data - - Returns: - List with one dict: `{ "type": "text", "text": "{...JSON...}" }` - where the JSON body contains `results`, `total_count`, and echo of `query`. - """ - if not await is_openai_mcp_client(context): - logger.warning("Rejected ChatGPT search request from non-OpenAI MCP client") - return _unsupported_search_client_response() - - logger.debug(f"ChatGPT search request: query='{query}'") - - try: - # Keep this adapter tiny: the real search behavior lives in bm_search. - # The connector has no project parameter; preserve its default-project scope. - project = await resolve_project_parameter(context=context) - results = await bm_search( - project=project, - query=query, - page=1, - page_size=10, - output_format="json", - context=context, - ) - - raw_results = results.get("results", []) if isinstance(results, dict) else [] - - formatted_results = _format_search_results_for_chatgpt(raw_results) - search_results = { - "results": formatted_results, - "total_count": len(raw_results), # Use actual count from results - "query": query, - } - logger.debug(f"Search completed: {len(formatted_results)} results returned") - - # Return in MCP content array format as required by OpenAI - return _text_content(search_results) - - except Refusal as error: - return _text_content( - { - "results": [], - "error": "Search temporarily unavailable" - if error.code == "UNAVAILABLE" - else "Search failed", - "error_message": "Search temporarily unavailable, retry shortly" - if error.code == "UNAVAILABLE" - else error.message, - } - ) - - except Exception as e: - logger.error(f"ChatGPT search failed for query '{query}': {e}") - error_results = { - "results": [], - "error": "Internal search error", - "error_message": str(e)[:200], - } - return _text_content(error_results) - - -async def fetch( - id: str, - context: Context | None = None, -) -> List[Dict[str, Any]]: - """ChatGPT/OpenAI MCP fetch adapter returning a single text content item. - - Args: - id: Document identifier (permalink, title, or memory URL) - context: Optional FastMCP context passed through for auth/session data - - Returns: - List with one dict: `{ "type": "text", "text": "{...JSON...}" }` - where the JSON body includes `id`, `title`, `text`, `url`, and metadata. - """ - if not await is_openai_mcp_client(context): - logger.warning("Rejected ChatGPT fetch request from non-OpenAI MCP client") - return _unsupported_fetch_client_response(id) - - logger.debug(f"ChatGPT fetch request: id='{id}'") - - try: - # Folder separators do not establish project scope. Only a recognized - # project prefix overrides this connector's default-project workflow. - detected = await detect_project_from_identifier_prefix(id, ConfigManager().config, context) - project = ( - detected.project_id or detected.project - if detected is not None - else await resolve_project_parameter(context=context) - ) - if project is None: - raise refusal("INVALID_ARGUMENTS", "No project could be resolved for this document.") - content = str( - await bm_read( - identifier=_identifier_for_bm_read(id), - include_frontmatter=False, - project=project, - context=context, - ) - ) - - # Format the document for ChatGPT - document = _format_document_for_chatgpt(content, id) - - logger.debug(f"Fetch completed: id='{id}', content_length={len(document.get('text', ''))}") - - # Return in MCP content array format as required by OpenAI - return _text_content(document) - - except Refusal as error: - return _text_content( - { - "id": id, - "title": "Document Not Found" if error.code == "NOT_FOUND" else "Fetch Error", - "text": error.message, - "url": id, - "metadata": { - "error": "Document not found" if error.code == "NOT_FOUND" else error.code - }, - } - ) - - except Exception as e: - logger.error(f"ChatGPT fetch failed for id '{id}': {e}") - error_document = { - "id": id, - "title": "Fetch Error", - "text": f"Failed to fetch document: {str(e)[:200]}", - "url": id, - "metadata": {"error": "Fetch failed"}, - } - return _text_content(error_document) diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py deleted file mode 100644 index f025de99e..000000000 --- a/src/basic_memory/mcp/tools/edit_note.py +++ /dev/null @@ -1,1160 +0,0 @@ -"""Edit note tool for Basic Memory MCP server.""" - -import json -from typing import Any, TYPE_CHECKING, Annotated, Literal, NoReturn, Optional - -import frontmatter -import logfire -from httpx import HTTPStatusError -from loguru import logger -from fastmcp import Context -from fastmcp.exceptions import ToolError -from pydantic import AliasChoices, BeforeValidator, Field - -if TYPE_CHECKING: # pragma: no cover - from basic_memory.mcp.clients import KnowledgeClient - -from basic_memory.config import ConfigManager -from basic_memory.file_utils import ( - dump_frontmatter, - has_frontmatter, - parse_frontmatter, - remove_frontmatter, -) -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, - add_project_metadata, - resolve_project_and_path, - routes_to_default_project, -) -from basic_memory.mcp.tools.utils import _extract_response_data, _response_detail_text -from basic_memory.schemas.base import Entity -from basic_memory.schemas.v2.entity import EntityResolveResponse, EntityResponseV2 -from basic_memory.schemas.v2.note_placement import ( - AmbiguousNotePlacementResponse, - NoteCreatePlacementResponse, - OutsideProjectPlacementResponse, -) -from basic_memory.services.link_resolver import ( - detect_project_from_workspace_identifier_prefix, - is_workspace_qualified_plain_identifier, -) -from basic_memory.utils import ( - coerce_dict, - normalize_project_reference, -) - -EDIT_OPERATIONS = ( - "append", - "prepend", - "find_replace", - "replace_section", - "insert_before_section", - "insert_after_section", -) - - -def _format_ambiguous_note_path_response( - placement: AmbiguousNotePlacementResponse, project_permalink: str -) -> str: - """Name both notes the path could create and the spelling that picks each.""" - return ( - f"# Edit Failed - Ambiguous Note Path\n\n" - f"'{placement.literal}' does not exist yet, and project '{project_permalink}' " - "has both folders it could name, so append/prepend will not guess where to " - "create it.\n\n" - f"- `{placement.below_prefix}` creates `{placement.below_prefix}.md`\n" - f"- `{project_permalink}/{placement.literal}` creates `{placement.literal}.md`\n" - ) - - -# Suffixes mimetypes maps to text/markdown (extension matching is case-insensitive), -# mirroring FileService.is_markdown which gates the index-file endpoint server-side. -_MARKDOWN_SUFFIXES = (".md", ".markdown") - - -async def _resolve_after_disk_recovery( - knowledge_client: "KnowledgeClient", - identifier: str, -) -> Optional[str]: - """Recover from a resolution miss when the note exists on disk but is not indexed. - - Trigger: identifier resolution failed with "not found", but the identifier may map - to a markdown file written directly to disk before the watcher indexed it (#581). - Why: editing an on-disk note should not require a manual full reindex or watcher restart. - Outcome: the single file is indexed server-side and resolution is retried exactly - once. Returns None when the identifier does not map to an indexable file on - disk, so the caller keeps its existing not-found handling. - """ - # Try the identifier as-is first so existing .markdown/.MD files are found; only - # fall back to appending markdown suffixes (".md" first, then ".markdown") when - # the identifier does not already carry one, so 'notes/foo.markdown' never becomes - # 'notes/foo.markdown.md' and a stem identifier still reaches 'notes/foo.markdown'. - candidates = [identifier] - if not identifier.lower().endswith(_MARKDOWN_SUFFIXES): - candidates.extend(f"{identifier}{suffix}" for suffix in _MARKDOWN_SUFFIXES) - - for candidate in candidates: - try: - indexed = await knowledge_client.index_file(candidate) - except ToolError as index_error: - # Trigger: the index-file request failed - # Why: 400/404 are the expected "nothing to recover" rejections (missing - # file, traversal, non-markdown) — except the ignored-path 400, which - # means the file exists on disk but the ignore rules forbid indexing - # it, so falling through to auto-create would silently shadow the - # file. Anything else — auth, server, transport-level failures — is a - # real error that must not be masked as a not-found miss. - # Outcome: ignored-path rejections raise a clear ToolError; other expected - # rejections try the next candidate or fall through to the caller's - # existing not-found behavior; unexpected failures propagate. - cause = index_error.__cause__ - candidate_rejected = isinstance( - cause, HTTPStatusError - ) and cause.response.status_code in (400, 404) - if not candidate_rejected: - raise - detail = _response_detail_text(_extract_response_data(cause.response)) or "" - if IGNORED_PATH_REJECTION_DETAIL in detail: - raise ToolError( - f"Note file '{candidate}' exists on disk but {IGNORED_PATH_REJECTION_DETAIL} " - "and will not be edited" - ) from index_error - logger.debug(f"edit_note disk recovery skipped for '{candidate}': {index_error}") - continue - - # Trigger: index-file succeeded and returned the indexed entity. - # Why: the server may have canonicalized the path casing (notes/Disk-Note -> - # notes/disk-note.md), so strictly re-resolving the raw identifier can - # still miss the entity we just indexed. - # Outcome: use the entity identity from the index-file response directly; only - # fall back to a strict re-resolve when an older server omits external_id, - # and let that re-resolve fail loudly instead of guessing. - if indexed.external_id: - logger.info( - f"edit_note indexed unindexed file '{candidate}' as entity {indexed.external_id}" - ) - return indexed.external_id - logger.info(f"edit_note indexed unindexed file '{candidate}'; retrying resolution") - return await knowledge_client.resolve_entity(identifier, strict=True) - - return None - - -def _compose_workspace_project_route( - *, - workspace: Optional[str], - project: Optional[str], - project_id: Optional[str], -) -> Optional[str]: - """Return the explicit project route requested by workspace/project args.""" - if workspace is None: - return project - - cleaned_workspace = workspace.strip().strip("/") - if not cleaned_workspace: - raise ValueError("workspace must not be empty when provided") - if "/" in cleaned_workspace: - raise ValueError("workspace must be a single workspace slug, name, or tenant_id") - if project_id is not None: - raise ValueError("workspace cannot be combined with project_id; use project_id alone") - if project is None or not project.strip().strip("/"): - raise ValueError("workspace requires an explicit project argument") - - cleaned_project = project.strip().strip("/") - if "/" in cleaned_project: - raise ValueError( - "Use either workspace='workspace' with project='project', " - "or project='workspace/project', not both" - ) - return f"{cleaned_workspace}/{cleaned_project}" - - -def _format_ambiguous_workspace_identifier_response( - *, - identifier: str, - detected_project: str, -) -> str: - """Format the safe-stop response for ambiguous plain write identifiers.""" - cleaned_identifier = identifier.strip() - normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/") - _, _, note_identifier = normalized_identifier.split("/", 2) - - return f"""# Edit Failed - Ambiguous Identifier - -`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`. - -Because bm_edit changes content, Basic Memory will not infer a workspace route from a plain path. - -Retry with one of these explicit routes: -- `bm_edit(identifier="{note_identifier}", project="{detected_project}", command=..., content=...)` -- `bm_edit(identifier="memory://{normalized_identifier}", command=..., content=...)` -- `bm_edit(identifier="{note_identifier}", project="", command=..., 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 `bm_list(path="/")` 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` (name, workspace/project, or external 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="{target_project_id}"`, or use `bm_list(path="/")` to confirm the intended project before editing.""" - - -class EditRefused(ToolError): - """A failed edit, already formatted for the caller. - - The outer handler in edit_note re-raises it unchanged instead of wrapping it again. - """ - - -def _raise_edit_failure(output_format: str, payload: dict[str, Any], text: str) -> NoReturn: - """Report a failed edit as a tool error, keeping the guidance for the caller. - - A returned "Edit Failed" string reads as success to MCP clients; raising makes - the result an error (isError) while the message still carries the same help. - """ - raise EditRefused(json.dumps(payload) if output_format == "json" else text) - - -def _write_was_refused(error: Exception) -> bool: - """Whether the API answered the write with a 4xx, so nothing was written. - - The HTTP helpers raise ToolError from the HTTPStatusError when a response arrived, - and from the TransportError when none did. - """ - cause = error.__cause__ - return isinstance(cause, HTTPStatusError) and 400 <= cause.response.status_code < 500 - - -def _unknown_outcome_guidance(identifier: str, project_external_id: str) -> str: - """Tell the caller to check the note before retrying a write that may have landed. - - The suggested read names the project by external_id: a project name can match a - same-named project in another workspace, where the append would look absent. - """ - return ( - "The edit may have been applied. Read the note before retrying; repeating an " - "append or prepend that already landed adds the content twice: " - f'bm_read(identifier="{identifier}", project="{project_external_id}")' - ) - - -def _format_unknown_outcome_response( - error_message: str, operation: str, identifier: str, project_external_id: str -) -> str: - """Format a failure that happened after the write was sent, with no refusal from the API.""" - return f"""# Edit Outcome Unknown - -The {operation} on note '{identifier}' was sent, but no confirmation came back: {error_message} - -{_unknown_outcome_guidance(identifier, project_external_id)}""" - - -def _revision_conflict_detail(error: Exception) -> dict[str, Any] | None: - """Return the structured body of a refused checksum precondition, else None. - - The API answers a stale base checksum with a 409 whose detail is - {"message": ..., "db_checksum": ...} (#1445). Other 409s, such as an ambiguous - identifier, carry a plain string detail and keep their ordinary handling. - """ - cause = error.__cause__ - if not isinstance(cause, HTTPStatusError) or cause.response.status_code != 409: - return None - data = _extract_response_data(cause.response) - detail = data.get("detail") if isinstance(data, dict) else None - if isinstance(detail, dict) and "db_checksum" in detail: - return detail - return None - - -def _format_revision_conflict_response( - identifier: str, current_checksum: str | None, project_external_id: str -) -> str: - """Explain a refused checksum-guarded edit and the ways forward.""" - if current_checksum is None: - return f"""# Edit Failed - Note Revision Conflict - -The note '{identifier}' no longer exists, so nothing was edited. Look the note up again -with bm_search before retrying; use bm_write to create a missing note.""" - return f"""# Edit Failed - Note Revision Conflict - -The note '{identifier}' changed since you read it, so nothing was edited. -Current checksum: `{current_checksum}` - -Re-read the note to see what changed (or fetch just its checksum with -`bm_read(identifier="{identifier}", project="{project_external_id}", include_content=False)`), -then retry with expected_checksum="{current_checksum}". Drop expected_checksum to edit -the current revision unconditionally.""" - - -def _format_error_response( - error_message: str, - operation: str, - identifier: str, - find_text: Optional[str] = None, - expected_replacements: int = 1, - project: Optional[str] = None, -) -> str: - """Format helpful error responses for edit_note failures that guide the AI to retry successfully.""" - - # Entity not found errors — only reachable for find_replace/replace_section - # because append/prepend auto-create the note when it doesn't exist - if "Entity not found" in error_message or "entity not found" in error_message.lower(): - return f"""# Edit Failed - Note Not Found - -The note with identifier '{identifier}' could not be found. The `find_replace` and `replace_section` operations require an existing note with content to modify. - -**Tip:** Use bm_write to create a missing note before editing it. - -## Suggestions to try: -1. **Create a missing note**: Use bm_write before calling bm_edit -2. **Search for the note first**: Use `bm_search(query="{identifier.split("/")[-1]}", project="{project or "project-name"}")` to find similar notes with exact identifiers -3. **File exists on disk but is not indexed yet?**: Run `basic-memory db reindex --search` or wait for the file watcher, then retry -4. **Try different exact identifier formats**: - - If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}" - - If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}" - - Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` first to verify the note exists and get the exact identifier - -## Alternative approach: -Use `bm_write(identifier="folder/title.md", content="content", project="{project or "project-name"}")` to create the note first, then edit it.""" - - # Find/replace specific errors - if operation == "find_replace": - if "Text to replace not found" in error_message: - return f"""# Edit Failed - Text Not Found - -The text '{find_text}' was not found in the note '{identifier}'. - -## Suggestions to try: -1. **Read the note first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to see the current content -2. **Check for exact matches**: The search is case-sensitive and must match exactly -3. **Try a broader search**: Search for just part of the text you want to replace -4. **Use expected_replacements=0**: If you want to verify the text doesn't exist - -## Alternative approaches: -- Use `append` or `prepend` to add new content instead -- Use `replace_section` if you're trying to update a specific section""" - - if "Expected" in error_message and "occurrences" in error_message: - # Extract the actual count from error message if possible - import re - - match = re.search(r"found (\d+)", error_message) - actual_count = match.group(1) if match else "a different number of" - - return f"""# Edit Failed - Wrong Replacement Count - -Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}. - -## How to fix: -1. **Read the note first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to see how many times '{find_text}' appears -2. **Update expected_replacements**: Set expected_replacements={actual_count} in your bm_edit call -3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific - -## Example: -``` -bm_edit(identifier="{identifier}", command="find_replace", content="new_text", project="{project or "project-name"}", find_text="{find_text}", expected_replacements={actual_count}) -```""" - - # Section replacement errors - if operation == "replace_section" and "Multiple sections" in error_message: - return f"""# Edit Failed - Duplicate Section Headers - -Multiple sections found with the same header in note '{identifier}'. - -## How to fix: -1. **Read the note first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to see the document structure -2. **Make headers unique**: Add more specific text to distinguish sections -3. **Use append instead**: Add content at the end rather than replacing a specific section - -## Alternative approach: -Use `find_replace` to update specific text within the duplicate sections.""" - - # Generic server/request errors - if ( - "Invalid request" in error_message or "malformed" in error_message.lower() - ): # pragma: no cover - return f"""# Edit Failed - Request Error - -There was a problem with the edit request to note '{identifier}': {error_message}. - -## Common causes and fixes: -1. **Note doesn't exist**: Use `bm_search(query="query", project="{project or "project-name"}")` or `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to verify the note exists -2. **Invalid identifier format**: Try different identifier formats (title vs permalink) -3. **Empty or invalid content**: Check that your content is properly formatted -4. **Server error**: Try the operation again, or use `bm_read()` first to verify the note state - -## Troubleshooting steps: -1. Verify the note exists: `bm_read(identifier="{identifier}", project="{project or "project-name"}")` -2. If not found, search for it: `bm_search(query="{identifier.split("/")[-1]}", project="{project or "project-name"}")` -3. Try again with the correct identifier from the search results""" - - # Fallback for other errors - return f"""# Edit Failed - -Error editing note '{identifier}': {error_message} - -## General troubleshooting: -1. **Verify the note exists**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to check -2. **Check your parameters**: Ensure all required parameters are provided correctly -3. **Read the note content first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to understand the current structure -4. **Try a simpler operation**: Start with `append` if other operations fail - -## Need help? -- Use `bm_search(query="query", project="{project or "project-name"}")` to find notes -- Use `bm_read(identifier="identifier", project="{project or "project-name"}")` to examine content before editing -- Check that identifiers, section headers, and find_text match exactly""" - - -async def edit_note( - identifier: str, - operation: Annotated[str, Field(json_schema_extra={"enum": list(EDIT_OPERATIONS)})], - # Accept common replacement-content aliases. Models trained on diff/patch - # APIs reach for new_content/replacement/replace_with on first try. - content: Annotated[ - str, - Field( - validation_alias=AliasChoices("content", "new_content", "replacement", "replace_with") - ), - ], - project: Optional[str] = None, - workspace: Optional[str] = None, - project_id: Optional[str] = None, - # Section/heading naming varies across tools; accept the descriptive forms. - section: Annotated[ - Optional[str], - Field( - default=None, - validation_alias=AliasChoices("section", "section_heading", "heading"), - ), - ] = None, - # find_text is the highest-frequency miss per the issue: models reach for - # find/old_text/old_content/search before find_text every time. - find_text: Annotated[ - Optional[str], - Field( - default=None, - validation_alias=AliasChoices("find_text", "find", "old_text", "old_content", "search"), - ), - ] = None, - expected_replacements: Optional[int] = None, - replace_subsections: Optional[bool] = None, - metadata: Annotated[Optional[dict[str, Any]], BeforeValidator(coerce_dict)] = None, - expected_checksum: str | None = None, - output_format: Literal["text", "json"] = "text", - context: Context | None = None, -) -> str | dict[str, Any]: - """Edit an existing markdown note in the knowledge base. - - Makes targeted changes to existing notes without rewriting the entire content. - - Project Resolution: - Server resolves projects in this order: Single Project Mode → project parameter → default project. - If project unknown, use list_memory_projects() or recent_activity() first. - - Args: - identifier: The exact title, permalink, or memory:// URL of the note to edit. - Must be an exact match - fuzzy matching is not supported for edit operations. - Use bm_search() or bm_read() first to find the correct identifier if uncertain. - From the CLI this is a positional argument, not a flag. - operation: The editing operation to perform: - - "append": Add content to the end of the note (creates the note if it doesn't exist) - - "prepend": Add content to the beginning of the note (creates the note if it doesn't exist) - - "find_replace": Replace occurrences of find_text with content (note must exist) - - "replace_section": Replace a markdown section identified by its header (note and section must exist). - The heading must match exactly, including trailing tags or text; a miss fails without writing. - By default the section spans through the next heading of the same or higher - level, so its subsections are replaced too; see replace_subsections. - - "insert_before_section": Insert content before a section heading without consuming it (note must exist) - - "insert_after_section": Insert content after a section heading without consuming it (note must exist) - content: The content to add or use for replacement - project: Project name to edit in. Optional - server will resolve using hierarchy. - Use "workspace/project" to route to a project in a specific cloud workspace. - If unknown, use list_memory_projects() to discover available projects. - workspace: Workspace slug, name, or tenant_id. When provided with `project`, - routes as `workspace/project`. Cannot be combined with `project_id`. - project_id: Project external_id (UUID). Prefer this over `project` when known — - it routes to the exact project regardless of name collisions across cloud - workspaces. Takes precedence over `project`. Get from list_memory_projects(). - section: Heading for replace_section, insert_before_section, and - insert_after_section (e.g. "## Notes", "### Implementation"). Must match exactly. - find_text: For find_replace operation - the exact, case-sensitive text to replace - expected_replacements: For find_replace: required occurrence count of find_text - (default 1). A mismatch fails without writing. - replace_subsections: For replace_section operation. Default (true): the section - spans everything through the next heading of the same or higher level in the - original note, so replacing "## Section" also replaces its "###" subsections — - the replacement content may freely introduce new headings. Set to false to - replace only the immediate content under the header, stopping at the next - heading of any level and preserving subsections. - metadata: Optional dict of frontmatter fields to merge, independent of `operation`. - Provided keys overwrite existing frontmatter values (or are added if new); - unrelated frontmatter keys and the note body are left untouched. Can be - combined with any operation in the same call. `title` and `permalink` are - ignored since those have their own dedicated handling; `type` is applied like - any other frontmatter field. Key deletion is not supported. - expected_checksum: Optional revision precondition: the full `checksum` that - bm_read, bm_write, or bm_edit returned in JSON mode (the accepted - revision's checksum; text output shows only its first 8 characters). When the - note has changed since, the edit is refused with a revision conflict that - names the current checksum, and nothing is written. A refused precondition - never auto-creates the note. To refresh a stale checksum without reading the - whole note, call bm_read(identifier=..., include_content=False). - output_format: "text" returns a markdown summary of the edit and the note's - resulting observations and relations. "json" returns machine-readable edit - metadata. - context: Optional FastMCP context for performance caching. - - Returns: - A markdown formatted summary of the edit operation and resulting semantic content, - including operation details, file path, observations, relations, and project metadata. - - Examples: - # Add new content to end of note - edit_note(identifier="project-planning", operation="append", content="\\n## New Requirements\\n- Feature X\\n- Feature Y", project="my-project") - - # Add timestamp at beginning (frontmatter-aware) - edit_note(identifier="meeting-notes", operation="prepend", content="## 2025-05-25 Update\\n- Progress update...\\n\\n", project="work-docs") - - # Update version number (single occurrence) - edit_note(identifier="config-spec", operation="find_replace", content="v0.13.0", project="api-project", find_text="v0.12.0") - - # Update version in multiple places with validation - edit_note(identifier="api-docs", operation="find_replace", content="v2.1.0", project="docs-project", find_text="v2.0.0", expected_replacements=3) - - # Replace text that appears multiple times - validate count first - edit_note(identifier="docs/guide", operation="find_replace", content="new-api", project="team-docs", find_text="old-api", expected_replacements=5) - - # Replace implementation section (subsections under it are replaced too) - edit_note(identifier="api-spec", operation="replace_section", content="New implementation approach...\\n", project="specs", section="## Implementation") - - # Replace only the intro text under a header, keeping its subsections - edit_note(identifier="api-spec", operation="replace_section", content="New intro...\\n", project="specs", section="## Implementation", replace_subsections=False) - - # Replace subsection with more specific header - edit_note(identifier="docs/setup", operation="replace_section", content="Updated install steps\\n", project="docs", section="### Installation") - - # Using different identifier formats (must be exact matches) - edit_note(identifier="Meeting Notes", operation="append", content="\\n- Follow up on action items", project="work-project") # exact title - edit_note(identifier="docs/meeting-notes", operation="append", content="\\n- Follow up tasks", project="work-project") # exact permalink - - # If uncertain about identifier, search first: - # search_notes(query="meeting", project="work-project") # Find available notes - # edit_note(identifier="docs/meeting-notes-2025", operation="append", content="content", project="work-project") # Use exact result - - # Add new section to document - edit_note(identifier="project-plan", operation="append", content="\\n## Future Work\\nTBD - needs research\\n", project="planning") - - # Update status across document (expecting exactly 2 occurrences) - edit_note(identifier="status-report", operation="find_replace", content="In Progress", project="reports", find_text="Not Started", expected_replacements=2) - - # Edit only if nobody changed the note since you read it - edit_note(identifier="docs/plan", operation="append", content="\\n- Next step", project="work", - expected_checksum="") - - # Update frontmatter fields without touching the body (any operation works; - # append with empty content is a no-op on the body itself) - edit_note(identifier="tickets/2026-06-18-printer-offline", operation="append", content="", project="support", - metadata={"status": "resolved", "closed_at": "2026-06-18T10:42:00Z"}) - - Raises: - ToolError: If the edit fails or is refused (for example, the note was modified - concurrently). The message carries the troubleshooting guidance, or the - structured result in JSON mode. - HTTPError: If project doesn't exist or is inaccessible - ValueError: If operation is invalid or required parameters are missing - SecurityError: If identifier attempts path traversal - - Note: - Edit operations require exact identifier matches. If unsure, use read_note() or - search_notes() first to find the correct identifier. When the identifier looks - like a file path and the file exists on disk but is not indexed yet, edit_note - indexes that file automatically and retries the edit. The tool provides detailed - error messages with suggestions if operations fail. - """ - # Resolve effective defaults: allow MCP clients to send null for optional scalar fields - effective_replacements = expected_replacements if expected_replacements is not None else 1 - effective_replace_subsections = replace_subsections if replace_subsections is not None else True - project = _compose_workspace_project_route( - workspace=workspace, - project=project, - project_id=project_id, - ) - - # Resolve or reject routable identifier prefixes before selecting a client. - # Trigger: no explicit project/project_id was provided. - # Why: memory:// URLs are explicit routes, but plain three-segment identifiers - # are ambiguous for a mutating tool. - # Outcome: memory:// can route; plain workspace/project/path matches stop with - # guidance instead of silently editing another project. - if project is None and project_id is None: - config = ConfigManager().config - if identifier.strip().startswith("memory://"): - route = await detect_project_from_memory_url_prefix( - identifier, - config, - context=context, - ) - if route is not None: - # The id rides along so the name is never re-resolved against a - # different accessible workspace holding the same permalink (#1432). - project, project_id = route.project, route.project_id - elif _workspace_identifier_discovery_available( - identifier, - config, - ) and is_workspace_qualified_plain_identifier(identifier): - # A refusal, not a route: this names the project the ambiguous - # identifier *would* have edited, for the message only. - detected = await detect_project_from_workspace_identifier_prefix( - identifier, - config, - context=context, - ) - if detected: - _raise_edit_failure( - output_format, - { - "title": None, - "permalink": None, - "file_path": None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": "AMBIGUOUS_IDENTIFIER", - "project": detected, - }, - _format_ambiguous_workspace_identifier_response( - identifier=identifier, - detected_project=detected, - ), - ) - - with logfire.span( - "mcp.tool.edit_note", - entrypoint="mcp", - tool_name="edit_note", - requested_project=project, - requested_project_id=project_id, - edit_operation=operation, - output_format=output_format, - has_section=bool(section), - has_find_text=bool(find_text), - expected_replacements=effective_replacements, - replace_subsections=effective_replace_subsections, - has_metadata=bool(metadata), - has_expected_checksum=expected_checksum is not None, - ): - # Decided before the client opens: a memory:// prefix on the identifier has - # already filled project in by now. - used_default = routes_to_default_project(project, project_id) - async with get_project_client(project, context=context, project_id=project_id) as ( - client, - active_project, - ): - logger.debug( - f"MCP tool call tool=edit_note project={active_project.name} " - f"identifier={identifier} operation={operation} output_format={output_format}" - ) - - # Validate operation - if operation not in EDIT_OPERATIONS: - raise ValueError( - f"Invalid operation '{operation}'. Must be one of: {', '.join(EDIT_OPERATIONS)}" - ) - - # Validate required parameters for specific operations - if operation == "find_replace" and not find_text: - raise ValueError("find_text parameter is required for find_replace operation") - section_ops = ("replace_section", "insert_before_section", "insert_after_section") - if operation in section_ops and not section: - raise ValueError("section parameter is required for section-based operations") - # Reject null metadata values before dispatch so both the edit path and the - # append/prepend auto-create fallback behave identically — the service-side - # guard only covers existing notes, and an auto-created note would otherwise - # be written with a YAML null that indexing silently filters out. - if metadata: - null_keys = sorted(k for k, v in metadata.items() if v is None) - if null_keys: - raise ValueError( - "metadata values cannot be null (key deletion is not supported): " - + ", ".join(null_keys) - ) - - # Set once the create or PATCH request leaves this process; the handler below - # needs it to tell a refusal from a write whose outcome is unknown. - write_sent = False - # The note the edit targets; a revision conflict re-reads it by id (#1719). - target_entity_id: str | None = None - # Import here to avoid circular import - from basic_memory.mcp.clients import KnowledgeClient - - # Use typed KnowledgeClient for API calls; the failure handler below also - # uses it to name the note in a revision conflict. - knowledge_client = KnowledgeClient(client, active_project.external_id) - # Use the PATCH endpoint to edit the entity - try: - 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: EntityResponseV2 | None = None - - # Try to resolve the entity; for append/prepend, create it if not found - try: - 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. - _raise_edit_failure( - output_format, - { - "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, - }, - _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 - target_entity_id = entity_id - except EditRefused: - # The cross-project refusal above must not be read as a missing note: - # its text says "Not Found", which would route it to auto-create. - raise - 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 - - # Trigger: resolution missed but the file may already exist on disk - # Why: files written directly to disk are invisible to identifier - # resolution until indexed; editing them should just work (#581) - # Outcome: the single file is indexed and resolution retried once - # A guarded edit skips recovery: the caller read a revision that - # no longer resolves, so the note was deleted. Its file can - # outlive the delete until cleanup runs, and indexing it here - # would resurrect the note with the caller's own checksum, - # which the precondition would then accept. - recovered_entity_id: str | None = None - placement: ( - NoteCreatePlacementResponse - | AmbiguousNotePlacementResponse - | OutsideProjectPlacementResponse - | None - ) = None - if is_not_found and expected_checksum is None: - # The server reads the identifier the way resolution did and - # judges folders from storage. Every reading is probed, so an - # unindexed file below the project prefix is found before a - # create could collide with it (#1775). - placement = await knowledge_client.place_note(identifier) - if isinstance(placement, OutsideProjectPlacementResponse): - # The server refused before probing any folder; nothing - # outside the project is recovered or created. - logger.warning( - "Attempted path traversal attack blocked", - path=placement.path, - project=active_project.name, - ) - _raise_edit_failure( - output_format, - { - "title": None, - "permalink": None, - "file_path": None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": "SECURITY_VALIDATION_ERROR", - }, - f"# Error\n\nNote path '{placement.path}' is not allowed - " - "paths must stay within project boundaries", - ) - for reading in placement.readings: - recovered_entity_id = await _resolve_after_disk_recovery( - knowledge_client, reading - ) - if recovered_entity_id is not None: - break - - 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 expected_checksum is not None: - # Trigger: the caller conditioned the edit on a revision it read, - # but the note no longer resolves. - # Why: auto-creating would write a note the caller never saw, - # which is exactly what the precondition exists to prevent. - # Outcome: the same revision conflict the API reports for a - # deleted note, with no current checksum. - _raise_edit_failure( - output_format, - { - "title": None, - "permalink": None, - "file_path": None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": "NOTE_REVISION_CONFLICT", - "currentChecksum": None, - }, - _format_revision_conflict_response( - identifier, None, active_project.external_id - ), - ) - elif placement is not None 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 - # content, while find_replace/replace_section require existing - # content to modify - # Outcome: note is created via the same path as write_note, where - # the server placed it, or refused when the path is ambiguous - if isinstance(placement, AmbiguousNotePlacementResponse): - _raise_edit_failure( - output_format, - { - "title": None, - "permalink": None, - "file_path": None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": "AMBIGUOUS_NOTE_PATH", - "candidates": [ - f"{placement.below_prefix}.md", - f"{placement.literal}.md", - ], - }, - _format_ambiguous_note_path_response( - placement, active_project.permalink - ), - ) - directory, title = placement.directory, placement.title - - entity = Entity( - title=title, - directory=directory, - note_type=metadata.get("type", "note") if metadata else "note", - content_type="text/markdown", - content=content, - entity_metadata=metadata, - ) - - # Trigger: auto-created content already declares a different type. - # Why: explicit metadata follows edit semantics and must win over the - # content frontmatter that create preparation reads back into note_type. - # Outcome: the canonical create path sees the validated metadata type. - if metadata and "type" in metadata and has_frontmatter(content): - content_metadata = parse_frontmatter(content) - content_metadata["type"] = entity.note_type - post = frontmatter.Post(remove_frontmatter(content, strip=False)) - post.metadata.update(content_metadata) - entity.content = dump_frontmatter(post) - - logger.info( - "Creating note via edit_note auto-create", - title=title, - directory=directory, - operation=operation, - ) - write_sent = True - result = await knowledge_client.create_entity(entity.model_dump()) - file_created = True - else: - # find_replace/replace_section require existing content — re-raise - raise resolve_error - - # --- Standard edit path (entity already existed) --- - if not file_created: - # Prepare the edit request data - edit_data = { - "operation": operation, - "content": content, - } - - # Add optional parameters - if section: - edit_data["section"] = section - if find_text: - edit_data["find_text"] = find_text - if effective_replacements != 1: # Only send if different from default - edit_data["expected_replacements"] = str(effective_replacements) - if not effective_replace_subsections: # Only send if different from default - edit_data["replace_subsections"] = False - if metadata: - edit_data["metadata"] = metadata - - # Call the PATCH endpoint - write_sent = True - result = await knowledge_client.patch_entity( - entity_id, edit_data, base_checksum=expected_checksum - ) - - # --- Format response --- - # result is always set: either by create_entity (auto-create) or patch_entity (edit) - assert result is not None - # Report the accepted revision's checksum: it is recorded at accept - # time and is what checksum-guarded edits compare base_checksum - # against. file_checksum arrives later from deferred materialization - # and can drift from it (#1586). - checksum = result.db_checksum - if file_created: - summary = [ - f"# Created note ({operation})", - f"project: {active_project.name}", - f"file_path: {result.file_path}", - f"permalink: {result.permalink}", - f"checksum: {checksum[:8] if checksum else 'unknown'}", - "fileCreated: true", - ] - lines_added = len(content.split("\n")) - summary.append(f"operation: Created note with {lines_added} lines") - else: - summary = [ - f"# Edited note ({operation})", - f"project: {active_project.name}", - f"file_path: {result.file_path}", - f"permalink: {result.permalink}", - f"checksum: {checksum[:8] if checksum else 'unknown'}", - ] - - # Add operation-specific details - if operation == "append": - lines_added = len(content.split("\n")) - summary.append(f"operation: Added {lines_added} lines to end of note") - elif operation == "prepend": - lines_added = len(content.split("\n")) - summary.append(f"operation: Added {lines_added} lines to beginning of note") - elif operation == "find_replace": - # For find_replace, we can't easily count replacements from here - # since we don't have the original content, but the server handled it - summary.append("operation: Find and replace operation completed") - elif operation == "replace_section": - summary.append(f"operation: Replaced content under section '{section}'") - elif operation == "insert_before_section": - summary.append(f"operation: Inserted content before section '{section}'") - elif operation == "insert_after_section": - summary.append(f"operation: Inserted content after section '{section}'") - - # Count observations by category (reuse logic from write_note) - categories = {} - if result.observations: - for obs in result.observations: - categories[obs.category] = categories.get(obs.category, 0) + 1 - - summary.append("\n## Observations") - for category, count in sorted(categories.items()): - summary.append(f"- {category}: {count}") - - # Count resolved/unresolved relations - unresolved = 0 - resolved = 0 - if result.relations: - unresolved = sum(1 for r in result.relations if not r.to_id) - resolved = len(result.relations) - unresolved - - summary.append("\n## Relations") - summary.append(f"- Resolved: {resolved}") - if unresolved: - summary.append(f"- Unresolved: {unresolved}") - - logger.debug( - f"MCP tool response: tool=edit_note project={active_project.name} " - f"operation={operation} permalink={result.permalink} " - f"observations_count={len(result.observations)} " - f"relations_count={len(result.relations)} " - f"file_created={str(file_created).lower()}" - ) - - if output_format == "json": - return { - "title": result.title, - "permalink": result.permalink, - "file_path": result.file_path, - "checksum": checksum, - "operation": operation, - "fileCreated": file_created, - } - - summary_result = "\n".join(summary) - return add_project_metadata( - summary_result, active_project.name, used_default=used_default - ) - - except EditRefused: - raise - except Exception as e: - logger.error(f"Error editing note: {e}") - # Trigger: the write was sent and the API did not answer it with a refusal - # (no response arrived, or a 5xx). - # Why: the server may have committed it, so "refused, retry" would invite - # a retry that applies an append or prepend twice. - # Outcome: still an error, but one that says to read the note first. - if write_sent and not _write_was_refused(e): - _raise_edit_failure( - output_format, - { - "title": None, - "permalink": None, - "file_path": None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": "EDIT_OUTCOME_UNKNOWN", - "detail": str(e), - "message": _unknown_outcome_guidance( - identifier, active_project.external_id - ), - }, - _format_unknown_outcome_response( - str(e), operation, identifier, active_project.external_id - ), - ) - # Trigger: the API refused expected_checksum because the accepted - # revision moved (or the note was deleted) since the caller read it. - # Why: the caller needs the current checksum to rebase, not the generic - # troubleshooting text. - # Outcome: a revision-conflict error naming the current checksum. - conflict = _revision_conflict_detail(e) if expected_checksum is not None else None - if conflict is not None: - current_checksum = conflict.get("db_checksum") - # Same shape as write_note's conflict (#1719): the note is named while - # it still exists, as it reads now, since the winning write may have - # retitled or moved it after this edit resolved it. A deleted note - # (no current checksum) has no identity. - named_note: EntityResolveResponse | None = None - if current_checksum is not None and target_entity_id is not None: - try: - named_note = await knowledge_client.resolve_entity_response( - target_entity_id, strict=True - ) - except ToolError: - # Deleted after the conflict: the refusal still stands and - # carries its checksum; there is just nothing left to name. - named_note = None - _raise_edit_failure( - output_format, - { - "title": named_note.title if named_note else None, - "permalink": named_note.permalink if named_note else None, - "file_path": named_note.file_path if named_note else None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": "NOTE_REVISION_CONFLICT", - "currentChecksum": current_checksum, - "message": _format_revision_conflict_response( - identifier, current_checksum, active_project.external_id - ), - }, - _format_revision_conflict_response( - identifier, current_checksum, active_project.external_id - ), - ) - if isinstance(e, UnresolvedProjectRouteError): - _raise_edit_failure( - output_format, - { - "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, - }, - _format_unresolved_project_route_response( - error=e, - active_project=active_project.name, - ), - ) - # A refused edit (for example a 409 when the note was modified - # concurrently) must not read as success, or the caller never retries it. - _raise_edit_failure( - output_format, - { - "title": None, - "permalink": None, - "file_path": None, - "checksum": None, - "operation": operation, - "fileCreated": False, - "error": str(e), - }, - _format_error_response( - str(e), - operation, - identifier, - find_text, - effective_replacements, - active_project.name, - ), - ) diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py deleted file mode 100644 index 1adba6ff3..000000000 --- a/src/basic_memory/mcp/tools/read_note.py +++ /dev/null @@ -1,644 +0,0 @@ -"""Read note tool for Basic Memory MCP server.""" - -from textwrap import dedent -from typing import Any, Annotated, Optional, Literal, cast -from uuid import UUID - -import logfire - -from loguru import logger -from fastmcp import Context -from fastmcp.exceptions import ToolError -from httpx import HTTPStatusError -from pydantic import AliasChoices, Field - -from basic_memory.config import ConfigManager -from basic_memory.markdown.line_scanning import format_line_read -from basic_memory.mcp.project_context import ( - detect_project_from_identifier_prefix, - get_project_client, - resolve_project_and_path, -) -from basic_memory.mcp.note_reads import ( - format_note_metadata, - parse_opening_frontmatter, - read_note_json_by_external_id, -) -from basic_memory.mcp.async_client import local_project_path -from basic_memory.mcp.tools.search import search_notes -from basic_memory.runtime.storage import runtime_file_path_is_markdown_note -from basic_memory.schemas.memory import memory_url_path -from basic_memory.utils import validate_project_path - -# The title-match fallback exists to find THE note by exact title, so it scans -# fixed-size pages of title results instead of the caller's page/page_size -# (which apply only to the text-search suggestion listing). -_TITLE_LOOKUP_PAGE_SIZE = 10 - -# Hard safety cap on title-lookup pages. The loop normally stops as soon as an -# exact match is found or results run out (has_more=False); the cap only bounds -# pathological knowledge bases where hundreds of fuzzy titles contain the -# queried phrase. Exhausting the cap falls through to the suggestion behavior. -_TITLE_LOOKUP_MAX_PAGES = 10 - - -def _is_exact_title_match(identifier: str, title: str) -> bool: - """Return True when identifier exactly matches a title (case-insensitive).""" - return identifier.strip().casefold() == title.strip().casefold() - - -def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]: - """Retain the existing test/import surface for the shared parser.""" - return parse_opening_frontmatter(content) - - -def _is_http_not_found(error: Exception) -> bool: - """Return True when a typed-client failure wraps an HTTP 404 response.""" - cause = error.__cause__ - return isinstance(cause, HTTPStatusError) and cause.response.status_code == 404 - - -def _exact_external_id(identifier: str) -> str | None: - """Return the canonical UUID when the whole identifier is an external ID.""" - try: - return str(UUID(identifier.strip())) - except ValueError: - return None - - -async def read_note( - identifier: str, - project: Optional[str] = None, - project_id: Optional[str] = None, - # Accept common pagination aliases models reach for from training data - # (page_number/limit/per_page), matching the sibling navigation tools - # (search_notes, build_context, recent_activity). The schema advertises - # only the canonical names; aliases are silently mapped at validation time. - # `offset` is intentionally NOT aliased: offset is item-indexed (skip N - # items) while page is a 1-indexed page-number, so direct aliasing would - # return the wrong slice. - page: Annotated[ - int, - Field(default=1, validation_alias=AliasChoices("page", "page_number")), - ] = 1, - page_size: Annotated[ - int, - Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")), - ] = 10, - output_format: Literal["text", "json"] = "text", - include_frontmatter: bool = False, - context: Context | None = None, - start_line: int | None = None, - end_line: int | None = None, - include_content: bool = True, -) -> str | dict[str, Any]: - """Return the raw markdown for a note, or guidance text if no match is found. - - Finds and retrieves a note by its title, permalink, or content search, - returning the raw markdown content including observations, relations, and metadata. - - Project Resolution: - Server resolves projects using a unified priority chain (same in local and cloud modes): - Single Project Mode → project parameter → default project. - Uses default project automatically. Specify `project` parameter to target a different project. - - This tool will try multiple lookup strategies to find the most relevant note: - 1. Direct permalink lookup - 2. Title search fallback - 3. Text search as last resort - - An explicit Markdown file path (ending in .md or .markdown) that does not - resolve directly is reported as not found without the search fallbacks. - - Args: - project: Project name to read from. Optional - when omitted, a project prefix - on a memory:// URL or permalink identifier routes the read; otherwise the - server uses the configured default project. If unknown, use - list_memory_projects() to discover available projects. - project_id: Project external_id (UUID). Prefer this over `project` when known — - it routes to the exact project regardless of name collisions across cloud - workspaces. Takes precedence over `project`. Get from list_memory_projects(). - identifier: The title or permalink of the note to read. - Can be a full memory:// URL, a permalink, a title, or search text. - From the CLI this is a positional argument, not a flag. - page: Page of fallback-search results to use when the identifier does not - resolve to a note directly (default: 1). A direct or exact-title match - returns the note content — page/page_size never chunk the - note itself, and the title-match lookup pages through fixed-size pages - of title results until an exact match is found or results are - exhausted, regardless of page or page_size. Aliases: page_number. - page_size: Number of fallback-search results per page (default: 10). When no - match is found, this caps how many related-note suggestions are listed. - Aliases: limit, per_page. - output_format: "text" returns markdown content or guidance text. - "json" returns a structured object with title/permalink/file_path/content/frontmatter. - Unresolved notes carry error="NOTE_NOT_FOUND" and a message, with - related_results when suggestions are available. - include_frontmatter: For unsliced JSON reads, include opening YAML in content; - parsed frontmatter is returned either way. Explicit line ranges are never - stripped. CLI: --frontmatter (--include-frontmatter is a deprecated alias). - start_line: First document line to read (1-based, inclusive). Defaults to 1 - when only end_line is given. Line scans count the full Markdown, - including frontmatter, matching cat's default line coordinates. - end_line: Last document line to read (inclusive); omitted means EOF. - Out-of-file ranges return empty content; invalid/reversed ranges fail. - With either bound, text output is numbered and JSON carries coordinates, - has_more, and next_start_line/next_end_line. include_frontmatter does - not strip an explicitly addressed range. Edits between calls may shift lines. - include_content: Default true. Set to false to return only the note's metadata — - title, permalink, file_path, the full accepted-revision checksum, and - updated_at — without the body. Use it to refresh the checksum that - bm_edit/bm_write take as expected_checksum without reading the whole - note. Cannot be combined with start_line/end_line. - context: Optional FastMCP context for performance caching. - - Returns: - Markdown content, a numbered line scan, note metadata, or helpful guidance if - not found. - - Examples: - # Read by permalink - read_note(identifier="specs/search-spec", project="my-research") - - # Read by title - read_note(identifier="Search Specification", project="work-project") - - # Read with memory URL - read_note(identifier="memory://specs/search-spec", project="my-research") - - # Read recent meeting notes - read_note(identifier="Weekly Standup", project="team-docs") - - # Fetch only the metadata and current checksum, without the body - read_note(identifier="specs/search-spec", project="my-research", include_content=False) - - # Page through fallback-search suggestions when nothing matches directly - read_note("unknown topic", page=2, page_size=5) - - Raises: - HTTPError: If project doesn't exist or is inaccessible - SecurityError: If identifier attempts path traversal - - Note: - If the exact note isn't found, this tool provides helpful suggestions - including related notes, search commands, and note creation templates. - """ - # Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative). - # Why: both flow into the fallback search's server-side slicing, where - # non-positive values produce empty result pages with unreachable - # pagination. Fail fast, matching search_notes/build_context. - if page < 1: - raise ValueError(f"page must be >= 1, got {page}") - if page_size < 1: - raise ValueError(f"page_size must be >= 1, got {page_size}") - - if start_line is not None and start_line < 1: - raise ValueError(f"start_line must be >= 1, got {start_line}") - if end_line is not None and end_line < (start_line or 1): - raise ValueError(f"end_line must be >= start_line, got {end_line}") - line_scan = start_line is not None or end_line is not None - if line_scan and not include_content: - raise ValueError("include_content=False cannot be combined with start_line/end_line") - # Metadata reads and line scans both need an exact entity, so they share the - # structured resolution path that JSON reads use instead of the text resource read. - structured_read = output_format == "json" or line_scan or not include_content - lines_param = f"{start_line or 1}-{'' if end_line is None else end_line}" if line_scan else None - - # Detect project from a memory URL or permalink prefix before routing. - # project_id routes by external UUID, so it bypasses URL discovery entirely. - if project is None and project_id is None: - detected = await detect_project_from_identifier_prefix( - identifier, - ConfigManager().config, - context=context, - ) - if detected is not None: - # The id rides along so the name is never re-resolved against a - # different accessible workspace holding the same permalink (#1432). - project, project_id = detected.project, detected.project_id - - with logfire.span( - "mcp.tool.read_note", - entrypoint="mcp", - tool_name="read_note", - requested_project=project, - requested_project_id=project_id, - page=page, - page_size=page_size, - output_format=output_format, - include_frontmatter=include_frontmatter, - include_content=include_content, - ): - async with get_project_client(project, context=context, project_id=project_id) as ( - client, - active_project, - ): - # Resolve identifier with project-prefix awareness for memory:// URLs. - # Pass active_project.name (the canonical resolved name) rather than the - # original `project` arg so the inner get_active_project cache hits even - # when project_id was used or `project` was wrong/ambiguous. - _, entity_path, _ = await resolve_project_and_path( - client, identifier, active_project.name, context - ) - - # Validate identifier to prevent path traversal attacks - # For memory:// URLs, validate the extracted path (not the raw URL which - # has a scheme prefix that confuses path validation) - raw_path = ( - memory_url_path(identifier) if identifier.startswith("memory://") else identifier - ) - processed_path = entity_path - project_path = local_project_path(client, active_project) - - if not validate_project_path(raw_path, project_path) or not validate_project_path( - processed_path, project_path - ): - logger.warning( - "Attempted path traversal attack blocked", - identifier=identifier, - processed_path=processed_path, - project=active_project.name, - ) - if output_format == "json": - return { - "title": None, - "permalink": None, - "file_path": None, - "content": None, - "frontmatter": None, - "error": "SECURITY_VALIDATION_ERROR", - } - return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries" - - # Get the file via REST API - first try direct identifier resolution - logger.debug( - "Attempting to read note from Project: {} identifier: {}", - active_project.name, - entity_path, - ) - - # Import here to avoid circular import - from basic_memory.mcp.clients import KnowledgeClient, ResourceClient - - # Use typed clients for API calls - knowledge_client = KnowledgeClient(client, active_project.external_id) - resource_client = ResourceClient(client, active_project.external_id) - - async def _read_resolved_note(entity_id: str) -> str | dict[str, Any]: - if not include_content: - return await _read_note_metadata(entity_id) - # Only explicit line scans use the sliced API in text mode. Ordinary - # text reads retain their resource path; UUID JSON reads stay one GET. - payload = await read_note_json_by_external_id( - knowledge_client=knowledge_client, - resource_client=resource_client, - entity_external_id=entity_id, - include_frontmatter=include_frontmatter, - lines=lines_param, - ) - if not line_scan: - return dict(payload) - first = payload["start_line"] - last = payload["end_line"] - total = payload["total_lines"] - width = last - first + 1 - if output_format == "text": - return format_line_read( - payload["content"], start_line=first, end_line=last, total_lines=total - ) - return { - **payload, - "has_more": last < total, - "next_start_line": last + 1 if last < total else None, - "next_end_line": min(total, last + width) if last < total else None, - } - - async def _read_note_metadata(entity_id: str) -> str | dict[str, Any]: - # One entity GET carries the accepted checksum; the body it also - # returns stays in-process and never reaches the caller. - entity = await knowledge_client.get_entity(entity_id) - metadata = { - "title": entity.title, - "permalink": entity.permalink, - "file_path": entity.file_path, - "checksum": entity.db_checksum, - "updated_at": entity.updated_at.isoformat(), - } - if output_format == "json": - return metadata - return format_note_metadata(metadata) - - def _not_found_json_payload() -> dict[str, Any]: - return { - "title": None, - "permalink": None, - "file_path": None, - "content": None, - "frontmatter": None, - "error": "NOTE_NOT_FOUND", - "message": f"Note not found: {identifier}", - } - - def _search_results(payload: object) -> list[dict[str, object]]: - if not isinstance(payload, dict): - return [] - payload_dict = cast(dict[str, object], payload) - results = payload_dict.get("results") - if not isinstance(results, list): - return [] - return [ - cast(dict[str, object], result) - for result in results - if isinstance(result, dict) - ] - - async def _search_candidates( - identifier_text: str, *, title_only: bool, lookup_page: int = 1 - ) -> dict[str, object]: - # Trigger: direct entity resolution failed for the caller's identifier. - # Why: search_notes applies the same memory:// normalization and tool-level - # query handling as the rest of MCP routing, which raw client calls skip. - # Outcome: unresolved memory URLs still fall back through normalized search. - # Pass project_id (external_id UUID) so the workspace selection from the - # outer get_project_client() is preserved across the inner re-resolution. - # Without this, project names that collide across workspaces could re-resolve - # to a different tenant via the default-workspace fallback (CLI/context=None). - search_type = "title" if title_only else "text" - # Trigger: title_only — the title search exists to find THE note by - # exact title, not to page through suggestions. - # Why: paginating it by the caller's page would skip an exact match - # sitting on page 1 (read_note("Exact Title", page=2)), and a - # small caller page_size could let a higher-ranked fuzzy title - # displace the exact match out of the lookup window - # (read_note("Foo Bar", page_size=1) when "Foo Bar Foo Bar" - # ranks first) — both returning suggestions instead of the note. - # Outcome: title lookup uses its own lookup_page with a fixed lookup - # size, walked by the caller below; caller page/page_size - # apply only to the text-search suggestion listing. - response = await search_notes( - project=active_project.name, - project_id=active_project.external_id, - query=identifier_text, - search_type=search_type, - page=lookup_page if title_only else page, - page_size=_TITLE_LOOKUP_PAGE_SIZE if title_only else page_size, - output_format="json", - context=context, - ) - # JSON searches return a dict even when empty. Text here is a - # formatted search failure, not evidence that the note is absent. - if not isinstance(response, dict): - if structured_read: - raise RuntimeError(f"Fallback search failed: {response}") - return {} - return cast(dict[str, object], response) - - def _result_title(item: dict[str, object]) -> str: - return str(item.get("title") or "") - - def _result_permalink(item: dict[str, object]) -> Optional[str]: - value = item.get("permalink") - return str(value) if value else None - - def _result_file_path(item: dict[str, object]) -> Optional[str]: - value = item.get("file_path") - return str(value) if value else None - - def _result_external_id(item: dict[str, object]) -> str | None: - value = item.get("external_id") - return value if isinstance(value, str) and value else None - - resolver_miss = False - if structured_read: - exact_external_id = _exact_external_id(entity_path) - if exact_external_id is not None: - try: - return await _read_resolved_note(exact_external_id) - except ToolError as error: - cause = error.__cause__ - # Only the entity GET proves this UUID is absent. A 404 - # from its resource fallback is a failed content read. - if ( - isinstance(cause, HTTPStatusError) - and cause.response.status_code == 404 - and cause.request.url.path.endswith( - f"/knowledge/entities/{exact_external_id}" - ) - ): - if line_scan: - # The slice endpoint also uses 404 for an existing - # non-Markdown entity. Confirm absence without slice - # parameters before classifying this error as missing. - try: - await knowledge_client.get_entity(exact_external_id) - except ToolError as lookup_error: - lookup_cause = lookup_error.__cause__ - if ( - not isinstance(lookup_cause, HTTPStatusError) - or lookup_cause.response.status_code != 404 - ): - raise - else: - raise error - if output_format == "json": - return _not_found_json_payload() - return format_not_found_message(active_project.name, identifier) - raise - - try: - entity_id = await knowledge_client.resolve_entity(entity_path, strict=True) - except ToolError as error: - # Search is a recovery for a confirmed lookup miss, not for - # unavailable or unauthorized resolution services. - if not _is_http_not_found(error): - raise - resolver_miss = True - logger.info(f"Direct lookup failed for '{entity_path}': {error}") - else: - logger.debug( - "Returning JSON read_note result from entity: {path}", - path=entity_path, - ) - return await _read_resolved_note(entity_id) - else: - # Text mode intentionally retains the resolve -> resource behavior, - # including its search recovery from operational lookup failures. - try: - entity_id = await knowledge_client.resolve_entity(entity_path, strict=True) - except Exception as error: - resolver_miss = _is_http_not_found(error) - logger.info(f"Direct lookup failed for '{entity_path}': {error}") - else: - try: - response = await resource_client.read(entity_id) - if response.status_code == 200: - logger.debug( - "Returning read_note result from resource: {path}", - path=entity_path, - ) - return response.text - except Exception as error: # pragma: no cover - logger.info(f"Resource read failed for '{entity_path}': {error}") - - # Trigger: the identifier names a Markdown file and strict resolution - # answered 404 for it. - # Why: strict resolution already matches file path, permalink, and exact title, - # so that 404 confirms the note is absent. Search could only add fuzzy - # suggestions, and in a project with no recorded full index pass it answers - # with index-required guidance, which JSON reads raised as an error (#1609). - # Only the resolver's 404 proves absence; an unavailable resolver or a - # failed resource read keeps the ordinary recovery path below. - # Outcome: an explicit path miss returns the ordinary not-found response. - if resolver_miss and runtime_file_path_is_markdown_note(entity_path): - if output_format == "json": - return _not_found_json_payload() - return format_not_found_message(active_project.name, identifier) - - # Fallback 1: Try title search via API, walking fixed-size pages of - # title results until an exact match is found or results run out. - # A single page is not enough: when more than _TITLE_LOOKUP_PAGE_SIZE - # higher-ranked fuzzy titles contain the queried phrase, the exact - # title lands on a later page and a one-page lookup would miss it. - logger.debug(f"Search title for: {identifier}") - result: dict[str, object] | None = None - for lookup_page in range(1, _TITLE_LOOKUP_MAX_PAGES + 1): - title_results = await _search_candidates( - identifier, title_only=True, lookup_page=lookup_page - ) - title_candidates = _search_results(title_results) - if not title_candidates: - logger.info( - f"No results in title search for: {identifier} " - f"in project {active_project.name}" - ) - break - # Trigger: direct resolution failed and title search returned candidates. - # Why: avoid returning unrelated notes when search yields only fuzzy matches. - # Outcome: fetch content only when a true exact title match exists. - result = next( - ( - candidate - for candidate in title_candidates - if _is_exact_title_match(identifier, _result_title(candidate)) - ), - None, - ) - if result is not None: - break - # Trigger: this page held only fuzzy titles and the server reports - # no further pages (has_more is False or absent). - # Why: continuing past the last page would issue empty lookups. - # Outcome: give up on the title fallback and try text search below. - if title_results.get("has_more") is not True: - logger.info(f"No exact title match found for: {identifier}") - break - - if result is not None and structured_read: - # An exact candidate identifies a note; retrieval failures must surface - # as operational errors instead of suggesting that it is missing. - entity_id = _result_external_id(result) - if entity_id is None and _result_permalink(result) is not None: - entity_id = await knowledge_client.resolve_entity( - _result_permalink(result) or "", strict=True - ) - if entity_id is not None: - logger.debug(f"Found note by exact title search: {_result_permalink(result)}") - return await _read_resolved_note(entity_id) - elif result is not None and _result_permalink(result): - try: - entity_id = await knowledge_client.resolve_entity( - _result_permalink(result) or "", strict=True - ) - response = await resource_client.read(entity_id) - if response.status_code == 200: - logger.debug( - f"Found note by exact title search: {_result_permalink(result)}" - ) - return response.text - except Exception as error: # pragma: no cover - logger.info( - "Failed to fetch content for found title match " - f"{_result_permalink(result)}: {error}" - ) - - # Fallback 2: Text search as a last resort - logger.info(f"Title search failed, trying text search for: {identifier}") - text_results = await _search_candidates(identifier, title_only=False) - - # We didn't find a direct match, construct a helpful error message - text_candidates = _search_results(text_results) - if not text_candidates: - if output_format == "json": - return _not_found_json_payload() - return format_not_found_message(active_project.name, identifier) - # The fallback search is paginated server-side to page_size, so list - # the whole returned page instead of a hardcoded cap — otherwise the - # caller's page_size would be silently ignored past the cap. - if output_format == "json": - payload = _not_found_json_payload() - payload["related_results"] = [ - { - "title": _result_title(result), - "permalink": _result_permalink(result), - "file_path": _result_file_path(result), - } - for result in text_candidates - ] - return payload - return format_related_results(active_project.name, identifier, text_candidates) - - -def format_not_found_message(project: str | None, identifier: str) -> str: - """Format the not-found result: what failed and the lookups that can recover it.""" - return dedent(f""" - # Note Not Found in {project}: "{identifier}" - - No note matches "{identifier}" in {project}. Titles and permalinks must match - exactly; title and text search found nothing either. - - To look further, try `bm_search(query="{identifier}", project="{project}")` or - `bm_search(since="7d", project="{project}")`. If the user wants this note - created, use `bm_write`. - """) - - -def format_related_results(project: str | None, identifier: str, results) -> str: - """Format the not-found result with the related notes the fallback search returned.""" - message = dedent(f""" - # Note Not Found in {project}: "{identifier}" - - No exact match for "{identifier}" in {project}. Related notes: - - """) - - for i, result in enumerate(results): - title = result.get("title") if isinstance(result, dict) else getattr(result, "title", None) - permalink = ( - result.get("permalink") - if isinstance(result, dict) - else getattr(result, "permalink", None) - ) - result_type = ( - result.get("type") if isinstance(result, dict) else getattr(result, "type", None) - ) - normalized_type = ( - result_type - if isinstance(result_type, str) - else str(getattr(result_type, "value", result_type)) - if result_type is not None - else None - ) - - message += dedent(f""" - ## {i + 1}. {title or "Untitled"} - - **Type**: {normalized_type or "entity"} - - **Permalink**: {permalink or "unknown"} - - """) - - message += dedent(f""" - Read one with `bm_read(identifier="", project="{project}")`, or search - further with `bm_search(query="{identifier}", project="{project}")`. - """) - - return message diff --git a/src/basic_memory/mcp/tools/workspaces.py b/src/basic_memory/mcp/tools/workspaces.py deleted file mode 100644 index 6c17cdcf9..000000000 --- a/src/basic_memory/mcp/tools/workspaces.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Workspace discovery MCP tool.""" - -from typing import Any, Literal - -from fastmcp import Context - -from basic_memory.mcp.project_context import get_available_workspaces -from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse - - -def _personal_workspace() -> WorkspaceInfo: - """Return a display-only personal workspace when discovery has no rows. - - This keeps list_workspaces friendly for non-teams or local-only users. It is not - the cloud routing source of truth; project-scoped routing still depends on real - workspace discovery and the workspace project index in project_context. - """ - return WorkspaceInfo( - tenant_id="personal", - workspace_type="personal", - slug="personal", - name="Personal", - role="owner", - is_default=True, - has_active_subscription=True, - ) - - -def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListResponse: - """Build the structured MCP response from the shared cloud workspace schema.""" - if not workspaces: - workspaces = [_personal_workspace()] - - default_workspace_id = next( - (workspace.tenant_id for workspace in workspaces if workspace.is_default), - None, - ) - return WorkspaceListResponse( - workspaces=workspaces, - count=len(workspaces), - default_workspace_id=default_workspace_id, - current_workspace_id=None, - ) - - -async def list_workspaces( - output_format: Literal["text", "json"] = "text", - context: Context | None = None, -) -> str | dict[str, Any]: - """List workspaces available to the current cloud user. - - Args: - output_format: "text" returns human-readable workspace list. - "json" returns structured workspace metadata. - context: Optional FastMCP context for progress/status logging. - """ - workspaces = await get_available_workspaces(context=context) - response = _workspace_list_response(workspaces) - - if output_format == "json": - return response.model_dump(mode="json") - - lines = [f"# Available Workspaces ({response.count})", ""] - for workspace in response.workspaces: - default_label = ", default" if workspace.is_default else "" - lines.append( - f"- {workspace.name} " - f"(slug={workspace.slug}, type={workspace.workspace_type}, " - f"role={workspace.role}{default_label}, tenant_id={workspace.tenant_id})" - ) - - return "\n".join(lines) diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 8e9c02e27..a78983fcb 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -1,7 +1,7 @@ """Service and helpers for resolving markdown links and permalink-like identifiers.""" import uuid as uuid_mod -from typing import Any, Optional, Tuple, Dict +from typing import Optional, Tuple, Dict from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -27,54 +27,6 @@ ) -def is_workspace_qualified_plain_identifier(identifier: str) -> bool: - """Return True for plain ``//`` identifiers.""" - stripped = identifier.strip() - if stripped.startswith("memory://"): - return False - - normalized = normalize_project_reference(stripped).strip("/") - return len(normalized.split("/", 2)) == 3 - - -async def detect_project_from_workspace_identifier_prefix( - identifier: str, - config: BasicMemoryConfig, - context: Any | None = None, -) -> Optional[str]: - """Resolve a project route from a plain workspace-qualified identifier.""" - if not is_workspace_qualified_plain_identifier(identifier): - return None - - from basic_memory.mcp.project_context import ( - _workspace_identifier_discovery_available, - resolve_workspace_qualified_identifier, - ) - - if not _workspace_identifier_discovery_available(identifier, config): - return None - - workspace_discovery_fallback_errors = ( - "not found", - "no accessible workspaces", - "unable to discover", - ) - try: - workspace_resolution = await resolve_workspace_qualified_identifier( - identifier, - context=context, - ) - except ValueError as exc: - message = str(exc).lower() - if any(error in message for error in workspace_discovery_fallback_errors): - return None - raise - - if workspace_resolution is None: - return None - return workspace_resolution.project_identifier - - def normalize_link_text(link_text: str) -> tuple[str, str | None]: """Strip wikilink syntax and return the target text plus optional alias.""" text = link_text.strip() diff --git a/test-int/mcp/test_adapter_project_routing.py b/test-int/mcp/test_adapter_project_routing.py index 479df8c14..81c22ae3a 100644 --- a/test-int/mcp/test_adapter_project_routing.py +++ b/test-int/mcp/test_adapter_project_routing.py @@ -1,13 +1,10 @@ """Native adapters select a project before calling the explicit-routing tools.""" -import json - import pytest from fastmcp import Client from fastmcp.client.transports import FastMCPTransport from mcp import types as mt -from basic_memory.config import ConfigManager import implementation_mcp @@ -55,33 +52,3 @@ async def test_prompts_select_project_with_multiple_projects( excluded = "DefaultOnlyMarker" if explicit_project else "OtherOnlyMarker" assert expected in text assert excluded not in text - - -@pytest.mark.asyncio -@pytest.mark.parametrize("id_form", ["search_hit", "relative_url", "qualified", "qualified_url"]) -async def test_connector_fetch_routes_relative_and_qualified_ids( - implementation_server, app, test_project, app_config, tmp_path, id_form -): - app_config.permalinks_include_project = False - ConfigManager().save_config(app_config) - async with Client( - implementation_server, client_info=mt.Implementation(name="openai-mcp", version="1.0.0") - ) as client: - await seed_projects( - client, test_project.name, str(tmp_path.parent / (tmp_path.name + "-other")) - ) - search = await client.call_tool("search", {"query": "Routing Topic"}) - search_items = json.loads(search.content[0].text) # pyright: ignore[reportAttributeAccessIssue] - hits = json.loads(search_items[0]["text"]) - identifier = hits["results"][0]["id"] - assert identifier == "notes/routing-topic" - if id_form in {"qualified", "qualified_url"}: - identifier = f"other/{identifier}" - if id_form in {"relative_url", "qualified_url"}: - identifier = f"memory://{identifier}" - fetched = await client.call_tool("fetch", {"id": identifier}) - fetched_items = json.loads(fetched.content[0].text) # pyright: ignore[reportAttributeAccessIssue] - document = json.loads(fetched_items[0]["text"]) - expected = "OtherOnlyMarker" if id_form.startswith("qualified") else "DefaultOnlyMarker" - assert document["metadata"] == {"format": "markdown"} - assert expected in document["text"] diff --git a/test-int/mcp/test_chatgpt_tools_integration.py b/test-int/mcp/test_chatgpt_tools_integration.py deleted file mode 100644 index 176d8cde2..000000000 --- a/test-int/mcp/test_chatgpt_tools_integration.py +++ /dev/null @@ -1,492 +0,0 @@ -""" -Integration tests for ChatGPT-compatible MCP tools. - -Tests the complete flow of search and fetch tools designed for ChatGPT integration, -ensuring they properly wrap Basic Memory's MCP tools and return OpenAI-compatible -MCP content array format. -""" - -import json -import pytest -from fastmcp import Client -from mcp import types as mt - -import implementation_mcp - -implementation_server = implementation_mcp.implementation_server - - -def openai_mcp_client(implementation_server): - return Client( - implementation_server, - client_info=mt.Implementation(name="openai-mcp", version="1.0.0"), - ) - - -def extract_mcp_json_content(mcp_result): - """ - Helper to extract JSON content from MCP CallToolResult. - - FastMCP auto-serializes our List[Dict[str, Any]] return values, so we need to: - 1. Get the content list from the CallToolResult - 2. Parse the JSON string in the text field (which is our serialized list) - 3. Extract the actual JSON from the MCP content array structure - """ - content_list = mcp_result.content - mcp_content_list = json.loads(content_list[0].text) - return json.loads(mcp_content_list[0]["text"]) - - -@pytest.mark.asyncio -async def test_chatgpt_search_basic(implementation_server, app, test_project): - """Test basic ChatGPT search functionality with MCP content array format.""" - - async with openai_mcp_client(implementation_server) as client: - # Create test notes for searching - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "Machine Learning Fundamentals", - "directory": "ai", - "content": "# Machine Learning Fundamentals\n\nIntroduction to ML concepts and algorithms.", - "tags": "ml,ai,fundamentals", - }, - ) - - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "Deep Learning with PyTorch", - "directory": "ai", - "content": "# Deep Learning with PyTorch\n\n" - "Building neural networks using PyTorch framework.", - "tags": "pytorch,deep-learning,ai", - }, - ) - - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "Data Visualization Guide", - "directory": "data", - "content": "# Data Visualization Guide\n\nCreating charts and graphs for data analysis.", - "tags": "visualization,data,charts", - }, - ) - - # Test ChatGPT search tool - search_result = await client.call_tool( - "search", - { - "query": "Machine Learning", - }, - ) - - # Extract JSON content from MCP result - results_json = extract_mcp_json_content(search_result) - assert "results" in results_json - assert len(results_json["results"]) > 0 - - # Check result structure - first_result = results_json["results"][0] - assert "id" in first_result - assert "title" in first_result - assert "url" in first_result - - # Verify correct content found — target note must be present - titles = [r["title"] for r in results_json["results"]] - assert "Machine Learning Fundamentals" in titles - - -@pytest.mark.asyncio -async def test_chatgpt_search_empty_results( - implementation_server, app, test_project, indexed_project -): - """Test ChatGPT search with no matching results.""" - - async with openai_mcp_client(implementation_server) as client: - # Search for non-existent content - search_result = await client.call_tool( - "search", - { - "query": "NonExistentTopic12345", - }, - ) - - # Extract JSON content from MCP result - results_json = extract_mcp_json_content(search_result) - assert "results" in results_json - assert len(results_json["results"]) == 0 - assert results_json["query"] == "NonExistentTopic12345" - - -@pytest.mark.asyncio -async def test_chatgpt_search_with_boolean_operators(implementation_server, app, test_project): - """Test ChatGPT search with boolean operators.""" - - async with openai_mcp_client(implementation_server) as client: - # Create test notes - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "Python Web Frameworks", - "directory": "dev", - "content": "# Python Web Frameworks\n\nComparing Django and Flask for web development.", - "tags": "python,web,frameworks", - }, - ) - - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "JavaScript Frameworks", - "directory": "dev", - "content": "# JavaScript Frameworks\n\nReact, Vue, and Angular comparison.", - "tags": "javascript,web,frameworks", - }, - ) - - # Test with AND operator - search_result = await client.call_tool( - "search", - { - "query": "Python AND frameworks", - }, - ) - - results_json = extract_mcp_json_content(search_result) - titles = [r["title"] for r in results_json["results"]] - # Python note must appear; JS note may also appear since FTS - # tokenizes broadly on shared terms like "frameworks" - assert "Python Web Frameworks" in titles - - -@pytest.mark.asyncio -async def test_chatgpt_fetch_document(implementation_server, app, test_project): - """Test ChatGPT fetch tool for retrieving full document content.""" - - async with openai_mcp_client(implementation_server) as client: - # Create a test note - note_content = """# Advanced Python Techniques - -## Overview -This document covers advanced Python programming techniques. - -## Topics Covered -- Decorators -- Context Managers -- Metaclasses -- Async/Await patterns - -## Code Examples -```python -def my_decorator(func): - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - return wrapper -``` -""" - - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "Advanced Python Techniques", - "directory": "programming", - "content": note_content, - "tags": "python,advanced,programming", - }, - ) - - # Fetch the document using its title - fetch_result = await client.call_tool( - "fetch", - { - "id": "Advanced Python Techniques", - }, - ) - - # Extract JSON content from MCP result - document_json = extract_mcp_json_content(fetch_result) - assert "id" in document_json - assert "title" in document_json - assert "text" in document_json - assert "url" in document_json - assert "metadata" in document_json - - # Verify content - assert document_json["title"] == "Advanced Python Techniques" - assert "Decorators" in document_json["text"] - assert "Context Managers" in document_json["text"] - assert "def my_decorator" in document_json["text"] - - -@pytest.mark.asyncio -async def test_chatgpt_fetch_by_permalink(implementation_server, app, test_project): - """Test ChatGPT fetch using permalink identifier.""" - - async with openai_mcp_client(implementation_server) as client: - # Create a note with known content - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "Test Document", - "directory": "test", - "content": "# Test Document\n\nThis is test content for permalink fetching.", - "tags": "test", - }, - ) - - # First search to get the permalink - search_result = await client.call_tool( - "search", - { - "query": "Test Document", - }, - ) - - results_json = extract_mcp_json_content(search_result) - assert len(results_json["results"]) > 0 - permalink = results_json["results"][0]["id"] - - # Fetch using the permalink - fetch_result = await client.call_tool( - "fetch", - { - "id": permalink, - }, - ) - - # Verify the fetched document - document_json = extract_mcp_json_content(fetch_result) - assert document_json["id"] == permalink - assert "Test Document" in document_json["title"] - assert "test content for permalink fetching" in document_json["text"] - - -@pytest.mark.asyncio -async def test_chatgpt_fetch_nonexistent_document(implementation_server, app, test_project): - """Test ChatGPT fetch with non-existent document ID.""" - - async with openai_mcp_client(implementation_server) as client: - # Try to fetch a non-existent document - fetch_result = await client.call_tool( - "fetch", - { - "id": "NonExistentDocument12345", - }, - ) - - # Extract JSON content from MCP result - document_json = extract_mcp_json_content(fetch_result) - - # Should have document structure even for errors - assert "id" in document_json - assert "title" in document_json - assert "text" in document_json - - # Check for error indication - assert document_json["id"] == "NonExistentDocument12345" - assert document_json["metadata"]["error"] == "Document not found" - assert "NonExistentDocument12345" in document_json["text"] - - -@pytest.mark.asyncio -async def test_chatgpt_fetch_with_empty_title(implementation_server, app, test_project): - """Test ChatGPT fetch handles documents with empty or missing titles.""" - - async with openai_mcp_client(implementation_server) as client: - # Create a note without a title in the content - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": "untitled-note", - "directory": "misc", - "content": "This is content without a markdown header.\n\nJust plain text.", - "tags": "misc", - }, - ) - - # Fetch the document - fetch_result = await client.call_tool( - "fetch", - { - "id": "untitled-note", - }, - ) - - # Parse JSON response - document_json = extract_mcp_json_content(fetch_result) - - # Should have a title even if content doesn't have one - assert "title" in document_json - assert document_json["title"] != "" - assert document_json["title"] is not None - assert "content without a markdown header" in document_json["text"] - - -@pytest.mark.asyncio -async def test_chatgpt_search_pagination_default(implementation_server, app, test_project): - """Test that ChatGPT search uses reasonable pagination defaults.""" - - async with openai_mcp_client(implementation_server) as client: - # Create more than 10 notes to test pagination - for i in range(15): - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": f"Test Note {i}", - "directory": "bulk", - "content": f"# Test Note {i}\n\nThis is test content number {i}.", - "tags": "test,bulk", - }, - ) - - # Search should return max 10 results by default - search_result = await client.call_tool( - "search", - { - "query": "Test Note", - }, - ) - - results_json = extract_mcp_json_content(search_result) - - # Should have at most 10 results (the default page_size) - assert len(results_json["results"]) <= 10 - assert results_json["total_count"] <= 10 - - -@pytest.mark.asyncio -async def test_chatgpt_tools_error_handling(implementation_server, app, test_project): - """Test error handling in ChatGPT tools returns proper MCP format.""" - - async with openai_mcp_client(implementation_server) as client: - # Test search with invalid query (if validation exists) - # Using empty query to potentially trigger an error - search_result = await client.call_tool( - "search", - { - "query": "", # Empty query might cause an error - }, - ) - - # Should still return MCP content array format - assert hasattr(search_result, "content") - content_list = search_result.content - assert isinstance(content_list, list) - assert len(content_list) == 1 - assert content_list[0].type == "text" - - # Should be valid JSON even on error - results_json = extract_mcp_json_content(search_result) - assert "results" in results_json # Should have results key even if empty - - -@pytest.mark.asyncio -async def test_chatgpt_tools_reject_default_client(implementation_server, app, test_project): - """Default FastMCP clients cannot use ChatGPT compatibility tools.""" - - async with Client(implementation_server) as client: - search_result = await client.call_tool( - "search", - { - "query": "Machine Learning", - }, - ) - - search_json = extract_mcp_json_content(search_result) - assert search_json["results"] == [] - assert search_json["error"] == "Unsupported MCP client" - assert "bm_search" in search_json["error_message"] - - fetch_result = await client.call_tool( - "fetch", - { - "id": "Machine Learning Fundamentals", - }, - ) - - document_json = extract_mcp_json_content(fetch_result) - assert document_json["id"] == "Machine Learning Fundamentals" - assert document_json["metadata"]["error"] == "Unsupported MCP client" - assert "bm_read" in document_json["text"] - - -@pytest.mark.asyncio -async def test_chatgpt_integration_workflow(implementation_server, app, test_project): - """Test complete workflow: search then fetch, as ChatGPT would use it.""" - - async with openai_mcp_client(implementation_server) as client: - # Step 1: Create multiple documents - docs = [ - { - "title": "API Design Best Practices", - "content": ( - "# API Design Best Practices\n\nRESTful API design principles and patterns." - ), - "tags": "api,rest,design", - }, - { - "title": "GraphQL vs REST", - "content": "# GraphQL vs REST\n\nComparing GraphQL and REST API architectures.", - "tags": "api,graphql,rest", - }, - { - "title": "Database Design Patterns", - "content": ( - "# Database Design Patterns\n\n" - "Common database design patterns and anti-patterns." - ), - "tags": "database,design,patterns", - }, - ] - - for doc in docs: - await client.call_tool( - "bm_write", - { - "project": test_project.name, - "title": doc["title"], - "directory": "architecture", - "content": doc["content"], - "tags": doc["tags"], - }, - ) - - # Step 2: Search for API-related content (as ChatGPT would) - search_result = await client.call_tool( - "search", - { - "query": "API", - }, - ) - - results_json = extract_mcp_json_content(search_result) - assert len(results_json["results"]) >= 2 - - # Step 3: Fetch one of the search results (as ChatGPT would) - first_result_id = results_json["results"][0]["id"] - fetch_result = await client.call_tool( - "fetch", - { - "id": first_result_id, - }, - ) - - document_json = extract_mcp_json_content(fetch_result) - - # Verify the fetched document matches search result - assert document_json["id"] == first_result_id - assert "API" in document_json["text"] or "api" in document_json["text"].lower() - - # Verify document has expected structure - assert document_json["metadata"]["format"] == "markdown" diff --git a/tests/implementation_mcp.py b/tests/implementation_mcp.py index a2d44824b..f1d47ae57 100644 --- a/tests/implementation_mcp.py +++ b/tests/implementation_mcp.py @@ -20,15 +20,10 @@ IMPLEMENTATION_NAMES = ( "cat", - "edit_note", - "fetch", "find", "grep", - "list_workspaces", "ls", "man", - "read_note", - "search", "search_notes", "tail", ) diff --git a/tests/mcp/test_line_scanning.py b/tests/mcp/test_line_scanning.py index b55e0ea34..84ccd7afa 100644 --- a/tests/mcp/test_line_scanning.py +++ b/tests/mcp/test_line_scanning.py @@ -4,11 +4,11 @@ from typing import Any import pytest +from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError -from httpx import HTTPStatusError, Request, Response from basic_memory.mcp.clients import KnowledgeClient, SearchClient -from basic_memory.mcp.tools import bm_write, cat, grep, read_note +from basic_memory.mcp.tools import bm_read, bm_write, cat, grep from basic_memory.schemas.search import SearchResponse from basic_memory.schemas.v2 import EntityResponseV2 @@ -36,7 +36,7 @@ async def test_scan_read_round_trip_and_token_savings(client, test_project) -> N window = row["windows"][0] assert window["match_lines"] == expected_matches[:2] - sliced = await read_note( + sliced = await bm_read( row["external_id"], start_line=window["start_line"], end_line=window["end_line"], @@ -59,7 +59,7 @@ async def test_scan_read_round_trip_and_token_savings(client, test_project) -> N assert cat_slice["content"] == sliced["content"] assert len(json.dumps(sliced)) < len(json.dumps(full)) / 10 - numbered = await read_note( + numbered = await bm_read( "Scan Note", start_line=expected_matches[0], end_line=expected_matches[0], @@ -77,12 +77,12 @@ async def test_ranges_include_frontmatter_and_have_eof_metadata(client, test_pro ) full = await cat("Bounds", project=test_project.name) lines = full["content"].splitlines() - opening = await read_note("Bounds", end_line=2, output_format="json", project=test_project.name) + opening = await bm_read("Bounds", end_line=2, output_format="json", project=test_project.name) assert isinstance(opening, dict) assert opening["content"] == "\n".join(lines[:2]) assert opening["start_line"] == 1 for end in (None, len(lines) + 100): - last = await read_note( + last = await bm_read( "Bounds", start_line=len(lines), end_line=end, @@ -95,7 +95,7 @@ async def test_ranges_include_frontmatter_and_have_eof_metadata(client, test_pro assert last["next_start_line"] is None and last["next_end_line"] is None # A range past the last line is an error, not an empty "Lines 1000-N" read (#1634). with pytest.raises(ToolError, match="start_line 1000 is past the end of the document"): - await read_note("Bounds", start_line=1000, output_format="json", project=test_project.name) + await bm_read("Bounds", start_line=1000, output_format="json", project=test_project.name) @pytest.mark.asyncio @@ -117,33 +117,11 @@ async def record( calls.append((entity_id, lines)) return await original(self, entity_id, section=section, lines=lines, max_tokens=max_tokens) - async def refuse_resolve(*args: object, **kwargs: object) -> str: - raise AssertionError("UUID must not resolve") - monkeypatch.setattr(KnowledgeClient, "get_entity", record) - monkeypatch.setattr(KnowledgeClient, "resolve_entity", refuse_resolve) - await read_note(identifier, end_line=2, project=test_project.name) + await bm_read(identifier, end_line=2, project=test_project.name) assert calls == [(identifier, "1-2")] -@pytest.mark.asyncio -async def test_exact_title_fallback_keeps_line_scan(client, test_project, monkeypatch) -> None: - await bm_write( - title="Exact Fallback", directory="test", content="alpha", project=test_project.name - ) - - async def refuse_resolve(*args: object, **kwargs: object) -> str: - request = Request("POST", "http://test/knowledge/resolve") - raise ToolError("force title search") from HTTPStatusError( - "Not found", request=request, response=Response(404, request=request) - ) - - monkeypatch.setattr(KnowledgeClient, "resolve_entity", refuse_resolve) - result = await read_note("Exact Fallback", end_line=1, project=test_project.name) - assert isinstance(result, str) - assert "1: ---" in result - - @pytest.mark.asyncio async def test_grep_candidate_pagination_survives_no_current_match( client, test_project, monkeypatch @@ -195,8 +173,14 @@ async def old_server( ) @pytest.mark.asyncio async def test_invalid_read_bounds_fail_before_routing(bounds: dict[str, int]) -> None: - with pytest.raises(ValueError): - await read_note("any", start_line=bounds.get("start_line"), end_line=bounds.get("end_line")) + # No app or database: the call must fail at validation, before any routing. + server = FastMCP("read-bounds") + server.tool(bm_read) + async with Client(server) as client: + result = await client.call_tool( + "bm_read", {"identifier": "any", "project": "any", **bounds}, raise_on_error=False + ) + assert result.is_error @pytest.mark.parametrize( diff --git a/tests/mcp/test_note_reads.py b/tests/mcp/test_note_reads.py new file mode 100644 index 000000000..18ca02f6a --- /dev/null +++ b/tests/mcp/test_note_reads.py @@ -0,0 +1,170 @@ +"""read_note_json_by_external_id: one entity read, the resource only when content is absent.""" + +from datetime import UTC, datetime + +import pytest +from httpx import Response + +from basic_memory.mcp.note_reads import ( + format_note_metadata, + parse_opening_frontmatter, + read_note_json_by_external_id, +) +from basic_memory.schemas.v2 import EntityResponseV2 + +ENTITY_ID = "22222222-2222-4222-8222-222222222222" + + +def _entity( + *, content: str | None = "---\ntitle: Request Count\nstatus: ready\n---\nBody\n" +) -> EntityResponseV2: + now = datetime(2026, 8, 13, tzinfo=UTC) + return EntityResponseV2( + external_id=ENTITY_ID, + id=1, + title="Request Count", + note_type="note", + permalink="notes/request-count", + file_path="notes/Request Count.md", + content=content, + entity_metadata={"title": "Indexed Title", "status": "indexed"}, + created_at=now, + updated_at=now, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("accepted_content", ["accepted content", ""]) +async def test_exact_id_helper_does_not_read_resource_for_present_content( + accepted_content: str, +) -> None: + class EntityReader: + calls = 0 + + async def get_entity( + self, + entity_id: str, + *, + section: str | None = None, + lines: str | None = None, + max_tokens: int | None = None, + ) -> EntityResponseV2: + assert section is None and lines is None and max_tokens is None + self.calls += 1 + assert entity_id == ENTITY_ID + return _entity(content=accepted_content) + + class ResourceReader: + calls = 0 + + async def read(self, entity_id: str) -> Response: + del entity_id + self.calls += 1 + raise AssertionError("present content must not fall back to resource") + + entity_reader = EntityReader() + resource_reader = ResourceReader() + result = await read_note_json_by_external_id( + knowledge_client=entity_reader, + resource_client=resource_reader, + entity_external_id=ENTITY_ID, + ) + + assert result["content"] == accepted_content + assert entity_reader.calls == 1 + assert resource_reader.calls == 0 + + +@pytest.mark.asyncio +async def test_exact_id_helper_reads_resource_once_only_when_content_is_absent() -> None: + class EntityReader: + calls = 0 + + async def get_entity( + self, + entity_id: str, + *, + section: str | None = None, + lines: str | None = None, + max_tokens: int | None = None, + ) -> EntityResponseV2: + assert section is None and lines is None and max_tokens is None + self.calls += 1 + assert entity_id == ENTITY_ID + return _entity(content=None) + + class ResourceReader: + calls = 0 + + async def read(self, entity_id: str) -> Response: + self.calls += 1 + assert entity_id == ENTITY_ID + return Response(200, text="---\nlegacy: true\n---\nlegacy body\n") + + entity_reader = EntityReader() + resource_reader = ResourceReader() + result = await read_note_json_by_external_id( + knowledge_client=entity_reader, + resource_client=resource_reader, + entity_external_id=ENTITY_ID, + ) + + assert result["content"].strip() == "legacy body" + assert result["frontmatter"] == {"legacy": True} + assert entity_reader.calls == 1 + assert resource_reader.calls == 1 + + +@pytest.mark.asyncio +async def test_exact_id_helper_does_not_fabricate_frontmatter_from_entity_metadata() -> None: + class EntityReader: + async def get_entity( + self, + entity_id: str, + *, + section: str | None = None, + lines: str | None = None, + max_tokens: int | None = None, + ) -> EntityResponseV2: + assert section is None and lines is None and max_tokens is None + assert entity_id == ENTITY_ID + return _entity(content="plain body\n") + + class ResourceReader: + async def read(self, entity_id: str) -> Response: + del entity_id + raise AssertionError("present content must not fall back to resource") + + result = await read_note_json_by_external_id( + knowledge_client=EntityReader(), + resource_client=ResourceReader(), + entity_external_id=ENTITY_ID, + ) + + assert result["content"] == "plain body\n" + assert result["frontmatter"] is None + + +def test_parse_opening_frontmatter_handles_crlf(): + """JSON note reads strip frontmatter from Windows-written markdown.""" + body, frontmatter = parse_opening_frontmatter( + "---\r\ntitle: Windows Note\r\ntype: note\r\n---\r\n\r\nBody text\r\n" + ) + + assert frontmatter == {"title": "Windows Note", "type": "note"} + assert body.strip() == "Body text" + + +def test_format_note_metadata_prints_the_full_checksum_and_marks_missing_values(): + """The text metadata read never truncates the checksum, and says when it has none.""" + checksum = "c" * 64 + text = format_note_metadata( + {"title": "T", "permalink": None, "file_path": "t.md", "checksum": checksum} + ) + assert text.splitlines() == [ + "# Note metadata", + "title: T", + "permalink: unknown", + "file_path: t.md", + f"checksum: {checksum}", + ] diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index e5736c237..7e2a234a6 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -13,7 +13,8 @@ import pytest_asyncio from basic_memory import db -from basic_memory.mcp.project_context import DetectedProjectRoute +from basic_memory.mcp.project_context import DetectedProjectRoute, resolve_project_and_path +from basic_memory.workspace_context import workspace_permalink_context from tests.mcp.conftest import ContextState, ctx @@ -3692,3 +3693,18 @@ async def fake_call_post(*args, **kwargs): context=ctx(context), ) assert resolved_path == "team-paul/manual/man3/write-note-3" + + +@pytest.mark.asyncio +async def test_resolve_project_and_path_reads_this_workspaces_qualified_route(client, test_project): + """Inside a workspace, 'memory:////...' keeps its full canonical path.""" + url = f"memory://team-paul/{test_project.permalink}/team/note" + + with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): + active_project, resolved_path, is_memory_url = await resolve_project_and_path( + client, url, project=test_project.name + ) + + assert active_project.external_id == test_project.external_id + assert resolved_path == f"team-paul/{test_project.permalink}/team/note" + assert is_memory_url is True diff --git a/tests/mcp/test_project_context_scoped_key.py b/tests/mcp/test_project_context_scoped_key.py index 540e512d4..a5e0a6852 100644 --- a/tests/mcp/test_project_context_scoped_key.py +++ b/tests/mcp/test_project_context_scoped_key.py @@ -62,124 +62,6 @@ async def reject_directory_as_project(*args: object, **kwargs: object) -> None: assert is_memory_url is True -@pytest.mark.asyncio -async def test_mutating_route_keeps_scope_rejection_fail_closed( - config_manager, - monkeypatch, -) -> None: - """Strict mutation routing must not reinterpret a forbidden route as a path.""" - context = ContextState() - active_project = ProjectItem( - id=1, - external_id="11111111-1111-1111-1111-111111111111", - name="ci-acceptance", - path="/tmp/ci-acceptance", - is_default=False, - ) - await context.set_state("active_project", active_project.model_dump()) - - async def reject_project_route(*args: object, **kwargs: object) -> None: - raise ToolError("This API key does not have access to this project") - - monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", reject_project_route) - - with pytest.raises(ToolError, match="does not have access"): - await resolve_project_and_path( - client=cast(Any, None), - identifier="memory://other-project/cloud-smoke", - project="ci-acceptance", - context=cast(Any, context), - strict_project_routing=True, - ) - - -@pytest.mark.asyncio -async def test_existing_target_mutation_can_fallback_for_missing_project( - config_manager, - monkeypatch, -) -> None: - """A missing project prefix may still identify a path for non-creating mutations.""" - config = config_manager.load_config() - config.permalinks_include_project = True - config_manager.save_config(config) - - context = ContextState() - active_project = ProjectItem( - id=1, - external_id="11111111-1111-1111-1111-111111111111", - name="ci-acceptance", - path="/tmp/ci-acceptance", - is_default=False, - ) - await context.set_state("active_project", active_project.model_dump()) - - async def reject_missing_project(*args: object, **kwargs: object) -> None: - raise ToolError("Project not found: 'src'") - - monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", reject_missing_project) - - resolved_project, resolved_path, is_memory_url = await resolve_project_and_path( - client=cast(Any, None), - identifier="memory://src/existing-note", - project="ci-acceptance", - context=cast(Any, context), - strict_project_routing=True, - allow_missing_project_fallback=True, - ) - - assert resolved_project == active_project - assert resolved_path == "ci-acceptance/src/existing-note" - assert is_memory_url is True - - -@pytest.mark.asyncio -async def test_validation_only_route_preserves_cached_active_project( - config_manager, - monkeypatch, -) -> None: - """A rejected cross-project mutation must not change subsequent routing state.""" - context = ContextState() - active_project = ProjectItem( - id=1, - external_id="11111111-1111-1111-1111-111111111111", - name="ci-acceptance", - path="/tmp/ci-acceptance", - is_default=False, - ) - await context.set_state("active_project", active_project.model_dump()) - - class ResolvedProjectResponse: - def json(self) -> dict[str, object]: - return { - "external_id": "22222222-2222-2222-2222-222222222222", - "project_id": 2, - "name": "other-project", - "permalink": "other-project", - "path": "/tmp/other-project", - "is_active": True, - "is_default": False, - "resolution_method": "name", - } - - async def resolve_other_project(*args: object, **kwargs: object) -> ResolvedProjectResponse: - return ResolvedProjectResponse() - - monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", resolve_other_project) - - resolved_project, _, _ = await resolve_project_and_path( - client=cast(Any, None), - identifier="memory://other-project/source-directory", - project="ci-acceptance", - context=cast(Any, context), - strict_project_routing=True, - allow_missing_project_fallback=True, - cache_resolved_project=False, - ) - - assert resolved_project.name == "other-project" - assert context.state["active_project"] == active_project.model_dump() - - @pytest.mark.asyncio async def test_read_route_requires_an_authorized_cached_project( config_manager, diff --git a/tests/mcp/test_read_note_request_counts.py b/tests/mcp/test_read_note_request_counts.py deleted file mode 100644 index 424573b77..000000000 --- a/tests/mcp/test_read_note_request_counts.py +++ /dev/null @@ -1,507 +0,0 @@ -"""Request-count contracts for semantic ``read_note`` JSON paths.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from datetime import UTC, datetime -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from fastmcp.exceptions import ToolError -from httpx import HTTPStatusError, ReadTimeout, Request, Response - -from basic_memory.mcp.note_reads import read_note_json_by_external_id -from basic_memory.schemas.v2 import EntityResponseV2 - -PROJECT_ID = "11111111-1111-4111-8111-111111111111" -ENTITY_ID = "22222222-2222-4222-8222-222222222222" - - -def lookup_miss() -> ToolError: - request = Request("POST", "http://test/knowledge/resolve") - response = Response(404, request=request) - error = ToolError("Note not found") - error.__cause__ = HTTPStatusError("Note not found", request=request, response=response) - return error - - -def _entity( - *, content: str | None = "---\ntitle: Request Count\nstatus: ready\n---\nBody\n" -) -> EntityResponseV2: - now = datetime(2026, 8, 13, tzinfo=UTC) - return EntityResponseV2( - external_id=ENTITY_ID, - id=1, - title="Request Count", - note_type="note", - permalink="notes/request-count", - file_path="notes/Request Count.md", - content=content, - entity_metadata={"title": "Indexed Title", "status": "indexed"}, - created_at=now, - updated_at=now, - ) - - -def _patch_project_routing(monkeypatch: pytest.MonkeyPatch, read_note_module: object) -> None: - @asynccontextmanager - async def fake_get_project_client( - project: str | None, - *, - context: object | None, - project_id: str | None, - ) -> AsyncIterator[tuple[object, SimpleNamespace]]: - del project, context, project_id - yield object(), SimpleNamespace(name="main", external_id=PROJECT_ID, home="/tmp") - - async def fake_resolve_project_and_path( - client: object, - identifier: str, - project: str, - context: object | None, - ) -> tuple[None, str, None]: - del client, project, context - return None, identifier, None - - monkeypatch.setattr(read_note_module, "get_project_client", fake_get_project_client) - monkeypatch.setattr(read_note_module, "resolve_project_and_path", fake_resolve_project_and_path) - monkeypatch.setattr(read_note_module, "validate_project_path", lambda *_args: True) - - -@pytest.mark.asyncio -async def test_exact_uuid_json_reads_entity_once_without_resolve_or_resource( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - calls = {"resolve": 0, "entity": 0, "resource": 0} - - class RecordingKnowledgeClient: - def __init__(self, client: object, project_id: str) -> None: - del client - assert project_id == PROJECT_ID - - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - del identifier, strict - calls["resolve"] += 1 - raise AssertionError("an exact external ID must not be resolved") - - async def get_entity(self, entity_id: str) -> EntityResponseV2: - calls["entity"] += 1 - assert entity_id == ENTITY_ID - return _entity() - - class RecordingResourceClient: - def __init__(self, client: object, project_id: str) -> None: - del client - assert project_id == PROJECT_ID - - async def read(self, entity_id: str) -> Response: - del entity_id - calls["resource"] += 1 - raise AssertionError("accepted entity content must avoid the resource route") - - monkeypatch.setattr(clients_module, "KnowledgeClient", RecordingKnowledgeClient) - monkeypatch.setattr(clients_module, "ResourceClient", RecordingResourceClient) - - result = await read_note_module.read_note(ENTITY_ID, project="main", output_format="json") - - assert isinstance(result, dict) - assert result["content"].strip() == "Body" - assert result["frontmatter"] == {"title": "Request Count", "status": "ready"} - assert calls == {"resolve": 0, "entity": 1, "resource": 0} - - -@pytest.mark.asyncio -async def test_permalink_json_resolves_once_then_reads_entity_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - calls = {"resolve": 0, "entity": 0, "resource": 0} - - class RecordingKnowledgeClient: - def __init__(self, client: object, project_id: str) -> None: - del client, project_id - - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - calls["resolve"] += 1 - assert identifier == "notes/request-count" - assert strict is True - return ENTITY_ID - - async def get_entity(self, entity_id: str) -> EntityResponseV2: - calls["entity"] += 1 - assert entity_id == ENTITY_ID - return _entity() - - class RecordingResourceClient: - def __init__(self, client: object, project_id: str) -> None: - del client, project_id - - async def read(self, entity_id: str) -> Response: - del entity_id - calls["resource"] += 1 - raise AssertionError("accepted entity content must avoid the resource route") - - monkeypatch.setattr(clients_module, "KnowledgeClient", RecordingKnowledgeClient) - monkeypatch.setattr(clients_module, "ResourceClient", RecordingResourceClient) - - result = await read_note_module.read_note( - "notes/request-count", - project="main", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["content"].strip() == "Body" - assert calls == {"resolve": 1, "entity": 1, "resource": 0} - - -@pytest.mark.asyncio -@pytest.mark.parametrize("fetch_fails", [False, True]) -async def test_exact_title_json_uses_search_result_external_id_without_second_resolve( - monkeypatch: pytest.MonkeyPatch, - fetch_fails: bool, -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - calls = {"resolve": 0, "entity": 0, "resource": 0} - - class RecordingKnowledgeClient: - def __init__(self, client: object, project_id: str) -> None: - del client, project_id - - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - calls["resolve"] += 1 - assert identifier == "Request Count" - assert strict is True - raise lookup_miss() - - async def get_entity(self, entity_id: str) -> EntityResponseV2: - calls["entity"] += 1 - assert entity_id == ENTITY_ID - if fetch_fails: - raise RuntimeError("entity fetch unavailable") - return _entity() - - class RecordingResourceClient: - def __init__(self, client: object, project_id: str) -> None: - del client, project_id - - async def read(self, entity_id: str) -> Response: - del entity_id - calls["resource"] += 1 - raise AssertionError("accepted entity content must avoid the resource route") - - async def fake_search_notes(*, search_type: str, **_kwargs: object) -> dict[str, object]: - assert search_type == "title" - return { - "results": [ - { - "title": "Request Count", - "external_id": ENTITY_ID, - "permalink": "notes/request-count", - "file_path": "notes/Request Count.md", - } - ], - "has_more": False, - } - - monkeypatch.setattr(clients_module, "KnowledgeClient", RecordingKnowledgeClient) - monkeypatch.setattr(clients_module, "ResourceClient", RecordingResourceClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes) - - if fetch_fails: - with pytest.raises(RuntimeError, match="entity fetch unavailable"): - await read_note_module.read_note("Request Count", project="main", output_format="json") - assert calls == {"resolve": 1, "entity": 1, "resource": 0} - return - - result = await read_note_module.read_note( - "Request Count", - project="main", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["content"].strip() == "Body" - assert calls == {"resolve": 1, "entity": 1, "resource": 0} - - -@pytest.mark.asyncio -async def test_text_mode_keeps_resolve_then_resource_behavior( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - calls = {"resolve": 0, "entity": 0, "resource": 0} - - class RecordingKnowledgeClient: - def __init__(self, client: object, project_id: str) -> None: - del client, project_id - - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - calls["resolve"] += 1 - assert identifier == "notes/request-count" - assert strict is True - return ENTITY_ID - - async def get_entity(self, entity_id: str) -> EntityResponseV2: - del entity_id - calls["entity"] += 1 - raise AssertionError("text mode must not load the entity response") - - class RecordingResourceClient: - def __init__(self, client: object, project_id: str) -> None: - del client, project_id - - async def read(self, entity_id: str) -> Response: - calls["resource"] += 1 - assert entity_id == ENTITY_ID - return Response(200, text="raw text-mode Markdown") - - monkeypatch.setattr(clients_module, "KnowledgeClient", RecordingKnowledgeClient) - monkeypatch.setattr(clients_module, "ResourceClient", RecordingResourceClient) - - result = await read_note_module.read_note("notes/request-count", project="main") - - assert result == "raw text-mode Markdown" - assert calls == {"resolve": 1, "entity": 0, "resource": 1} - - -@pytest.mark.asyncio -@pytest.mark.parametrize("accepted_content", ["accepted content", ""]) -async def test_exact_id_helper_does_not_read_resource_for_present_content( - accepted_content: str, -) -> None: - class EntityReader: - calls = 0 - - async def get_entity( - self, - entity_id: str, - *, - section: str | None = None, - lines: str | None = None, - max_tokens: int | None = None, - ) -> EntityResponseV2: - assert section is None and lines is None and max_tokens is None - self.calls += 1 - assert entity_id == ENTITY_ID - return _entity(content=accepted_content) - - class ResourceReader: - calls = 0 - - async def read(self, entity_id: str) -> Response: - del entity_id - self.calls += 1 - raise AssertionError("present content must not fall back to resource") - - entity_reader = EntityReader() - resource_reader = ResourceReader() - result = await read_note_json_by_external_id( - knowledge_client=entity_reader, - resource_client=resource_reader, - entity_external_id=ENTITY_ID, - ) - - assert result["content"] == accepted_content - assert entity_reader.calls == 1 - assert resource_reader.calls == 0 - - -@pytest.mark.asyncio -async def test_exact_id_helper_reads_resource_once_only_when_content_is_absent() -> None: - class EntityReader: - calls = 0 - - async def get_entity( - self, - entity_id: str, - *, - section: str | None = None, - lines: str | None = None, - max_tokens: int | None = None, - ) -> EntityResponseV2: - assert section is None and lines is None and max_tokens is None - self.calls += 1 - assert entity_id == ENTITY_ID - return _entity(content=None) - - class ResourceReader: - calls = 0 - - async def read(self, entity_id: str) -> Response: - self.calls += 1 - assert entity_id == ENTITY_ID - return Response(200, text="---\nlegacy: true\n---\nlegacy body\n") - - entity_reader = EntityReader() - resource_reader = ResourceReader() - result = await read_note_json_by_external_id( - knowledge_client=entity_reader, - resource_client=resource_reader, - entity_external_id=ENTITY_ID, - ) - - assert result["content"].strip() == "legacy body" - assert result["frontmatter"] == {"legacy": True} - assert entity_reader.calls == 1 - assert resource_reader.calls == 1 - - -@pytest.mark.asyncio -async def test_exact_id_helper_does_not_fabricate_frontmatter_from_entity_metadata() -> None: - class EntityReader: - async def get_entity( - self, - entity_id: str, - *, - section: str | None = None, - lines: str | None = None, - max_tokens: int | None = None, - ) -> EntityResponseV2: - assert section is None and lines is None and max_tokens is None - assert entity_id == ENTITY_ID - return _entity(content="plain body\n") - - class ResourceReader: - async def read(self, entity_id: str) -> Response: - del entity_id - raise AssertionError("present content must not fall back to resource") - - result = await read_note_json_by_external_id( - knowledge_client=EntityReader(), - resource_client=ResourceReader(), - entity_external_id=ENTITY_ID, - ) - - assert result["content"] == "plain body\n" - assert result["frontmatter"] is None - - -@pytest.mark.asyncio -@pytest.mark.parametrize("failed_search", ["title", "text"]) -async def test_fallback_search_failure_is_not_reported_as_missing( - monkeypatch: pytest.MonkeyPatch, failed_search: str -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - monkeypatch.setattr( - clients_module.KnowledgeClient, - "resolve_entity", - AsyncMock(side_effect=lookup_miss()), - ) - - async def search_result(*, search_type: str, **_kwargs: object) -> dict[str, object] | str: - if search_type == failed_search: - return "Search service unavailable; retry later" - return {"results": [], "has_more": False} - - monkeypatch.setattr(read_note_module, "search_notes", search_result) - with pytest.raises(RuntimeError, match="Search service unavailable"): - await read_note_module.read_note("Unknown Note", project="main", output_format="json") - - -@pytest.mark.asyncio -@pytest.mark.parametrize("status_code", [None, 403, 429, 503]) -@pytest.mark.parametrize("identifier", ["Note Title", ENTITY_ID]) -async def test_operational_lookup_errors_propagate_without_search( - monkeypatch: pytest.MonkeyPatch, status_code: int | None, identifier: str -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - request = Request("GET", f"http://test/v2/projects/{PROJECT_ID}/knowledge/entities/{ENTITY_ID}") - error = ToolError("Service request failed") - if status_code is None: - error.__cause__ = ReadTimeout("Timed out", request=request) - else: - response = Response(status_code, request=request) - error.__cause__ = HTTPStatusError( - "Service request failed", request=request, response=response - ) - method = "get_entity" if identifier == ENTITY_ID else "resolve_entity" - monkeypatch.setattr(clients_module.KnowledgeClient, method, AsyncMock(side_effect=error)) - search = AsyncMock(return_value={"results": []}) - monkeypatch.setattr(read_note_module, "search_notes", search) - - with pytest.raises(ToolError) as raised: - await read_note_module.read_note(identifier, project="main", output_format="json") - assert raised.value is error - search.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_uuid_resource_404_is_a_read_error_not_a_missing_entity( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - monkeypatch.setattr( - clients_module.KnowledgeClient, "get_entity", AsyncMock(return_value=_entity(content=None)) - ) - request = Request("GET", f"http://test/v2/projects/{PROJECT_ID}/resource/{ENTITY_ID}") - response = Response(404, request=request) - error = ToolError("Content unavailable") - error.__cause__ = HTTPStatusError("Content unavailable", request=request, response=response) - monkeypatch.setattr(clients_module.ResourceClient, "read", AsyncMock(side_effect=error)) - - with pytest.raises(ToolError) as raised: - await read_note_module.read_note(ENTITY_ID, project="main", output_format="json") - assert raised.value is error - - -@pytest.mark.asyncio -@pytest.mark.parametrize("confirmation_status", [None, 503]) -async def test_failed_unsliced_confirmation_does_not_report_missing( - monkeypatch: pytest.MonkeyPatch, confirmation_status: int | None -) -> None: - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_module = importlib.import_module("basic_memory.mcp.clients") - _patch_project_routing(monkeypatch, read_note_module) - request = Request("GET", f"http://test/v2/projects/{PROJECT_ID}/knowledge/entities/{ENTITY_ID}") - slice_error = ToolError("No Markdown content to slice") - slice_error.__cause__ = HTTPStatusError( - "No Markdown content to slice", request=request, response=Response(404, request=request) - ) - confirmation_error = ToolError("Confirmation unavailable") - if confirmation_status is None: - confirmation_error.__cause__ = ReadTimeout("Timed out", request=request) - else: - confirmation_error.__cause__ = HTTPStatusError( - "Unavailable", request=request, response=Response(confirmation_status, request=request) - ) - get_entity = AsyncMock(side_effect=[slice_error, confirmation_error]) - monkeypatch.setattr(clients_module.KnowledgeClient, "get_entity", get_entity) - - with pytest.raises(ToolError) as raised: - await read_note_module.read_note( - ENTITY_ID, project="main", output_format="json", start_line=1 - ) - assert raised.value is confirmation_error - assert get_entity.await_count == 2 diff --git a/tests/mcp/test_refusals.py b/tests/mcp/test_refusals.py index 522c12540..4cccf7025 100644 --- a/tests/mcp/test_refusals.py +++ b/tests/mcp/test_refusals.py @@ -68,3 +68,12 @@ def test_transport_failures_are_not_refusals(): error.__cause__ = httpx.ConnectError("down") assert api_refusal(error) is None + + +def test_a_plain_409_is_not_a_revision_conflict(): + """Only a 409 carrying the {"db_checksum": ...} detail pins a revision.""" + refused = api_refusal(_client_error(409, json={"detail": "ambiguous identifier"})) + + assert refused is not None + assert (refused.message, refused.revision_conflict) == ("ambiguous identifier", False) + assert refused.current_checksum is None diff --git a/tests/mcp/test_remote_path_validation.py b/tests/mcp/test_remote_path_validation.py index 75b9bedf0..ac1f14dbf 100644 --- a/tests/mcp/test_remote_path_validation.py +++ b/tests/mcp/test_remote_path_validation.py @@ -1,6 +1,5 @@ """MCP tools must not interpret remote project paths on their own host.""" -import importlib from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path @@ -9,19 +8,18 @@ from fastmcp.exceptions import ToolError from httpx import AsyncClient, MockTransport, Request, Response -from basic_memory.mcp.tools import bm_write +from basic_memory.mcp import note_targets +from basic_memory.mcp.tools import bm_read, bm_write from basic_memory.schemas.project_info import ProjectItem -@pytest.mark.parametrize("tool", ["read_note", "bm_write"]) +@pytest.mark.parametrize("tool", ["bm_read", "bm_write"]) async def test_remote_tool_reaches_api_despite_host_symlink( tool: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - # bm_write routes through note_targets; read_note owns its own routing. - module_name = "mcp.note_targets" if tool == "bm_write" else f"mcp.tools.{tool}" - module = importlib.import_module(f"basic_memory.{module_name}") + # Both tools route through note_targets. root = tmp_path / "project" root.mkdir() outside = tmp_path / "outside" @@ -42,19 +40,10 @@ async def routed( ) -> AsyncIterator[tuple[AsyncClient, ProjectItem]]: yield client, project - async def resolve_path( - client: AsyncClient, path: str, *args: object, **kwargs: object - ) -> tuple[ProjectItem, str, bool]: - return project, path, False - - monkeypatch.setattr(module, "get_project_client", routed) - if tool == "read_note": - monkeypatch.setattr(module, "resolve_project_and_path", resolve_path) - if tool == "read_note": - result = await module.read_note( - "escape/note.md", project="project", output_format="json" - ) - assert result["error"] == "NOTE_NOT_FOUND" + monkeypatch.setattr(note_targets, "get_project_client", routed) + if tool == "bm_read": + with pytest.raises(ToolError, match="^NOT_FOUND:"): + await bm_read("escape/note.md", project="project", context=None) else: with pytest.raises(ToolError, match="not found in object storage"): await bm_write( diff --git a/tests/mcp/test_required_project.py b/tests/mcp/test_required_project.py index c13a1ee89..713994060 100644 --- a/tests/mcp/test_required_project.py +++ b/tests/mcp/test_required_project.py @@ -2,7 +2,6 @@ import ast import importlib -import json import re from typing import Any from unittest.mock import AsyncMock @@ -13,7 +12,6 @@ from basic_memory.mcp import tools from basic_memory.mcp.refusals import Refusal -from basic_memory.mcp.tools import chatgpt_tools SCOPED_TOOLS = { "bm_read": {"identifier": "notes/plan.md"}, @@ -142,17 +140,6 @@ async def test_conflict_recovery_call_includes_scope_and_can_be_dispatched(clien assert "# Plan" in str(result.content) -@pytest.mark.asyncio -async def test_chatgpt_fetch_reports_unresolved_scope(monkeypatch): - monkeypatch.setattr(chatgpt_tools, "is_openai_mcp_client", AsyncMock(return_value=True)) - monkeypatch.setattr( - chatgpt_tools, "detect_project_from_identifier_prefix", AsyncMock(return_value=None) - ) - monkeypatch.setattr(chatgpt_tools, "resolve_project_parameter", AsyncMock(return_value=None)) - (result,) = await chatgpt_tools.fetch("Plan") - assert json.loads(result["text"])["metadata"]["error"] == "INVALID_ARGUMENTS" - - @pytest.mark.asyncio @pytest.mark.parametrize( ("module_name", "prompt_name", "arguments"), diff --git a/tests/mcp/test_tool_bm_edit.py b/tests/mcp/test_tool_bm_edit.py index efded57b7..e89b0e05e 100644 --- a/tests/mcp/test_tool_bm_edit.py +++ b/tests/mcp/test_tool_bm_edit.py @@ -3,6 +3,7 @@ These run the real ASGI stack through the shared `client` fixture. """ +from pathlib import Path from typing import Any import httpx @@ -10,10 +11,13 @@ from fastmcp import Client from fastmcp.exceptions import ToolError +from basic_memory import db from basic_memory.index.local_project import LocalProjectIndexRunner from basic_memory.mcp.clients import KnowledgeClient from basic_memory.mcp.server import mcp from basic_memory.mcp.tools import bm_edit, bm_read, bm_write +from basic_memory.mcp.tools.edit import SECTION_COMMANDS +from basic_memory.workspace_context import workspace_permalink_context NOTE = ( "---\ntitle: Plan\nstatus: draft\n---\n\n# Plan\n\n## Goals\n\n- ship\n\n" @@ -319,3 +323,316 @@ async def test_guarded_edit_of_a_note_already_gone(client, test_project): expected_checksum="abc", project=test_project.name, ) + + +# --- Sections (ported from edit_note) --- + +SPEC = ( + "# Spec\n\n## Implementation\nOld details.\n\n### Internals\nOld internals.\n\n" + "## Testing\nTest info here." +) + + +async def _seed_spec(project_name: str) -> None: + await bm_write(identifier="specs/spec.md", content=SPEC, project=project_name) + + +async def spec_text(project_name: str) -> str: + text = await bm_read("specs/spec.md", include_frontmatter=False, project=project_name) + assert isinstance(text, str) + return text + + +@pytest.mark.asyncio +async def test_replace_section_takes_its_subsections_by_default(client, test_project): + """The h3 under the h2 goes with it, through the next h2 (#1012).""" + await _seed_spec(test_project.name) + + await bm_edit( + identifier="specs/spec.md", + command="replace_section", + section="Implementation", + content="New details.\n\n## Rollout\nShip behind a flag.\n", + project=test_project.name, + ) + + text = await spec_text(test_project.name) + assert "New details." in text and "## Rollout" in text + assert "### Internals" not in text and "Old internals." not in text + assert "Test info here." in text + + +@pytest.mark.asyncio +async def test_replace_section_can_keep_its_subsections(client, test_project): + await _seed_spec(test_project.name) + + await bm_edit( + identifier="specs/spec.md", + command="replace_section", + section="Implementation", + content="New details.\n", + replace_subsections=False, + project=test_project.name, + ) + + text = await spec_text(test_project.name) + assert "New details." in text and "Old details." not in text + assert "### Internals\nOld internals." in text + assert "Test info here." in text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("command", "expected"), + [ + ("insert_before_section", "INSERTED\n\n## Testing"), + ("insert_after_section", "## Testing\nINSERTED"), + ], +) +async def test_insert_around_a_section(client, test_project, command, expected): + await _seed_spec(test_project.name) + + await bm_edit( + identifier="specs/spec.md", + command=command, + section="Testing", + content="INSERTED", + project=test_project.name, + ) + + assert expected in await spec_text(test_project.name) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", sorted(SECTION_COMMANDS)) +async def test_a_missing_section_changes_nothing(client, test_project, command): + await _seed_spec(test_project.name) + before = await spec_text(test_project.name) + + with pytest.raises(ToolError, match="^INVALID_ARGUMENTS: .*Nonexistent"): + await bm_edit( + identifier="specs/spec.md", + command=command, + section="Nonexistent", + content="x", + project=test_project.name, + ) + + assert await spec_text(test_project.name) == before + + +# --- find_replace counts --- + + +@pytest.mark.asyncio +async def test_find_replace_must_match_the_expected_count(client, test_project): + await bm_write( + identifier="config/config.md", + content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.", + project=test_project.name, + ) + + with pytest.raises(ToolError, match="^INVALID_ARGUMENTS: .*Expected 1 .*found 2"): + await bm_edit( + identifier="config/config.md", + command="find_replace", + find_text="v0.12.0", + content="v0.13.0", + project=test_project.name, + ) + await bm_edit( + identifier="config/config.md", + command="find_replace", + find_text="v0.12.0", + content="v0.13.0", + expected_replacements=2, + project=test_project.name, + ) + + text = await bm_read("config/config.md", project=test_project.name) + assert isinstance(text, str) + assert "v0.12.0" not in text and text.count("v0.13.0") == 2 + + +@pytest.mark.asyncio +async def test_whitespace_find_text_is_refused(client, test_project): + await _seed(test_project.name) + + with pytest.raises(ToolError, match="^INVALID_ARGUMENTS:"): + await bm_edit( + identifier="notes/plan.md", + command="find_replace", + find_text=" ", + content="x", + project=test_project.name, + ) + + +# --- Identifiers --- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "identifier", + ["notes/plan", "Plan", "notes/Plan", "notes/plan.md", "memory://notes/plan"], +) +async def test_identifier_spellings_edit_the_same_note(client, test_project, identifier): + await _seed(test_project.name) + + result = await edit_json( + identifier=identifier, command="append", content="tail", project=test_project.name + ) + + assert result["file_path"] == "notes/plan.md" + + +@pytest.mark.asyncio +async def test_memory_url_with_the_project_prefix(client, test_project): + await _seed(test_project.name) + + result = await edit_json( + identifier=f"memory://{test_project.name}/notes/plan", + command="append", + content="tail", + project=test_project.name, + ) + + assert result["action"] == "edited" + assert (await body(test_project.name)).rstrip().endswith("tail") + + +@pytest.mark.asyncio +async def test_a_near_miss_edits_nothing(client, test_project): + """A missing note never falls through to a similar title.""" + for title in ("Routing Test A", "Routing Test B"): + await bm_write( + title=title, directory="test", content=f"# {title}\nContent.", project=test_project.name + ) + + with pytest.raises(ToolError, match="^NOT_FOUND:"): + await bm_edit( + identifier="Routing Test NONEXISTENT", + command="find_replace", + find_text="Content", + content="replaced", + project=test_project.name, + ) + + for title in ("Routing Test A", "Routing Test B"): + assert "Content." in str(await bm_read(title, project=test_project.name)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "identifier", + ["../escape-note", "/tmp/outside-note", "notes/../../../escape", "~/escape-note"], +) +async def test_identifiers_outside_the_project_write_nothing(client, test_project, identifier): + project_root = Path(test_project.path) + + with pytest.raises(ToolError, match="^(NOT_FOUND|OUTSIDE_PROJECT|INVALID_ARGUMENTS):"): + await bm_edit( + identifier=f"{test_project.permalink}/{identifier}", + command="append", + content="should never be written", + project=test_project.name, + ) + + assert not (project_root.parent / "escape-note.md").exists() + assert not (project_root.parent / "escape.md").exists() + assert not Path("/tmp/outside-note.md").exists() + + +@pytest.mark.asyncio +async def test_team_workspace_memory_url_keeps_the_complete_permalink(client, test_project): + expected_permalink = f"team-paul/{test_project.name}/team/tool-edit-note" + with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): + await bm_write( + title="Tool Edit Note", + directory="team", + content="# Tool Edit Note\nOriginal content.", + project=test_project.name, + ) + result = await edit_json( + identifier=f"memory://{expected_permalink}", + command="append", + content="\nAppended in team workspace.", + project=test_project.name, + ) + + assert result["permalink"] == expected_permalink + + +# --- What an edit leaves behind --- + + +@pytest.mark.asyncio +async def test_repeated_edits_keep_the_permalink(client, test_project): + """A note without a permalink in its frontmatter keeps the one it was indexed with (#170).""" + await bm_write( + title="Plain", directory="test", content="# Plain\nOriginal.", project=test_project.name + ) + + for line in ("First edit.", "Second edit."): + result = await edit_json( + identifier="test/plain", command="append", content=line, project=test_project.name + ) + assert result["permalink"] == f"{test_project.name}/test/plain" + + +@pytest.mark.asyncio +async def test_empty_append_is_accepted(client, test_project): + await _seed(test_project.name) + + result = await edit_json( + identifier="notes/plan.md", command="append", content="", project=test_project.name + ) + + assert result["action"] == "edited" + + +@pytest.mark.asyncio +async def test_explicit_nulls_for_unused_fields_are_accepted(client, test_project): + """MCP clients may send null for every optional field an append does not use.""" + await _seed(test_project.name) + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool( + "bm_edit", + { + "identifier": "notes/plan.md", + "command": "append", + "content": "tail", + "find_text": None, + "section": None, + "metadata": None, + "expected_checksum": None, + "project": test_project.name, + }, + ) + + assert not result.is_error + assert (await body(test_project.name)).rstrip().endswith("tail") + + +@pytest.mark.asyncio +async def test_appended_observations_and_relations_are_indexed( + client, test_project, entity_repository, session_maker +): + await bm_write( + identifier="features/spec.md", + content="# Feature Spec\n\n- [design] Initial design #architecture\n- implements [[Base]]", + project=test_project.name, + ) + + await bm_edit( + identifier="features/spec.md", + command="append", + content="\n- [implementation] Added a feature #development\n- relates_to [[User Guide]]", + project=test_project.name, + ) + + async with db.scoped_session(session_maker) as session: + entity = await entity_repository.get_by_file_path(session, "features/spec.md") + assert entity is not None + assert {o.category for o in entity.observations} == {"design", "implementation"} + assert {r.relation_type for r in entity.outgoing_relations} == {"implements", "relates_to"} diff --git a/tests/mcp/test_tool_bm_read.py b/tests/mcp/test_tool_bm_read.py index e44d55344..eddba68e3 100644 --- a/tests/mcp/test_tool_bm_read.py +++ b/tests/mcp/test_tool_bm_read.py @@ -13,8 +13,10 @@ from mcp.types import EmbeddedResource, ImageContent from PIL import Image as PILImage +from basic_memory import db from basic_memory.index.local_project import LocalProjectIndexRunner from basic_memory.mcp.tools import bm_read, bm_write +from basic_memory.workspace_context import workspace_permalink_context LONG_NOTE = "\n".join( ["# Plan", "", "## Goals", "- [goal] ship 1.0", "", "## Decisions"] @@ -257,3 +259,218 @@ async def test_a_palette_png_is_shown_as_an_image(client, test_project, non_note result = await bm_read(non_note_files["palette"], project=test_project.name) assert isinstance(result, ImageContent) + + +# --- Identifier spellings (ported from read_note) --- + + +@pytest.mark.asyncio +async def test_unicode_content_round_trips(client, test_project): + content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦" + await _write(test_project.name, "Unicode Test", content, directory="test") + + text = await read_text("test/unicode-test", project=test_project.name) + + assert content in text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("by_external_id", [False, True]) +async def test_memory_url_with_the_project_prefix(client, test_project, by_external_id): + """The prefix agrees with `project`, whether that names the project or its id.""" + await _write(test_project.name, "Prefixed", "prefixed body", directory="test") + project = test_project.external_id if by_external_id else test_project.name + + payload = await read_json(f"memory://{test_project.name}/test/prefixed", project=project) + + assert payload["content"].rstrip().endswith("prefixed body") + + +@pytest.mark.asyncio +async def test_team_workspace_note_reads_by_its_complete_permalink( + client, test_project, entity_repository, session_maker +): + expected_permalink = f"team-paul/{test_project.name}/team/team-workspace-note" + with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): + written = await bm_write( + title="Team Workspace Note", + directory="team", + content="Team canonical content", + project=test_project.name, + ) + assert f"permalink: {expected_permalink}" in written + async with db.scoped_session(session_maker) as session: + stored = await entity_repository.get_by_permalink(session, expected_permalink) + assert stored is not None + + for identifier in (expected_permalink, f"memory://{expected_permalink}"): + payload = await read_json(identifier, project=test_project.name) + assert payload["permalink"] == expected_permalink + assert payload["content"].rstrip().endswith("Team canonical content") + + +@pytest.mark.asyncio +async def test_team_workspace_permalink_is_complete_without_project_prefixes( + client, test_project, config_manager +): + config = config_manager.load_config() + config.permalinks_include_project = False + config_manager.save_config(config) + expected_permalink = f"team-paul/{test_project.name}/team/team-no-project-prefix-note" + + with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): + written = await bm_write( + title="Team No Project Prefix Note", + directory="team", + content="Team content without project-prefix config", + project=test_project.name, + ) + assert f"permalink: {expected_permalink}" in written + + payload = await read_json(f"memory://{expected_permalink}", project=test_project.name) + + assert payload["permalink"] == expected_permalink + + +@pytest.mark.asyncio +async def test_personal_workspace_write_stores_the_complete_permalink( + client, test_project, entity_repository, session_maker +): + expected_permalink = f"personal/{test_project.name}/personal/personal-workspace-note" + with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"): + written = await bm_write( + title="Personal Workspace Note", + directory="personal", + content="Personal canonical content", + project=test_project.name, + ) + + assert f"permalink: {expected_permalink}" in written + async with db.scoped_session(session_maker) as session: + stored = await entity_repository.get_by_permalink(session, expected_permalink) + assert stored is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("workspace_qualified", [False, True]) +async def test_personal_workspace_still_reads_a_short_project_permalink( + client, test_project, workspace_qualified +): + """Notes written before workspace permalinks keep resolving, qualified or not.""" + short_permalink = f"{test_project.name}/personal/short-personal-note" + await _write( + test_project.name, "Short Personal Note", "Short personal content", directory="personal" + ) + identifier = f"personal/{short_permalink}" if workspace_qualified else short_permalink + + with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"): + payload = await read_json(f"memory://{identifier}", project=test_project.name) + + assert payload["permalink"] == short_permalink + assert payload["content"].rstrip().endswith("Short personal content") + + +# --- Identifiers that leave the project --- + +TRAVERSAL_IDENTIFIERS = [ + "../secrets.txt", + "../../etc/passwd", + "notes/../../../etc/shadow", + "..\\..\\Windows\\System32\\config\\SAM", + "\\\\server\\share\\file.txt", + "/etc/passwd", + "C:\\Users\\user\\.env", + "~/.ssh/id_rsa", + "~\\AppData\\secrets", + "memory://../../etc/passwd", + "memory:///etc/passwd", + "memory://notes/../../../etc/shadow", + "notes/文档/../../../etc/passwd", + "../" * 100 + "etc/malicious", + " ../../../etc/passwd ", +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identifier", TRAVERSAL_IDENTIFIERS) +async def test_identifiers_outside_the_project_read_nothing(client, test_project, identifier): + """The project-scoped API resolves identifiers, so none of these can reach a file.""" + await _write(test_project.name, "Inside", "inside body") + + with pytest.raises(ToolError, match="^(NOT_FOUND|OUTSIDE_PROJECT|INVALID_ARGUMENTS):"): + await bm_read(identifier, project=test_project.name) + + +# --- Misses and line bounds --- + + +@pytest.mark.asyncio +async def test_missing_external_id_line_read_is_not_found(client, test_project): + with pytest.raises(ToolError, match="^NOT_FOUND:"): + await bm_read( + "22222222-2222-4222-8222-222222222222", + start_line=1, + end_line=10, + project=test_project.name, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_line_read_past_the_end_reports_the_document_length( + client, test_project, entity_repository, session_maker, output_format +): + """A start_line past the last line is an error, not a 'Lines 500-N' read (#1634).""" + await _write(test_project.name, "Short Lines", "one\ntwo") + total = len( + (await read_json("notes/Short Lines.md", project=test_project.name))["content"].splitlines() + ) + async with db.scoped_session(session_maker) as session: + stored = await entity_repository.get_by_file_path(session, "notes/Short Lines.md") + assert stored is not None + + for identifier in ("notes/Short Lines.md", stored.external_id): + with pytest.raises( + ToolError, match=f"start_line 500 is past the end of the document \\({total} lines\\)" + ): + await bm_read( + identifier, + start_line=500, + end_line=510, + output_format=output_format, + project=test_project.name, + ) + + +@pytest.mark.asyncio +async def test_read_checksum_guards_an_overwrite(client, test_project): + """The checksum bm_read returns is the revision bm_write's expected_checksum checks.""" + written = await bm_write( + identifier="notes/guarded.md", + content="first revision", + output_format="json", + project=test_project.name, + ) + read = await read_json("notes/guarded.md", project=test_project.name) + assert isinstance(written, dict) + assert read["checksum"] == written["checksum"] + + replaced = await bm_write( + identifier="notes/guarded.md", + content="second revision", + overwrite=True, + expected_checksum=read["checksum"], + output_format="json", + project=test_project.name, + ) + assert isinstance(replaced, dict) + assert replaced["action"] == "updated" + + with pytest.raises(ToolError, match="REVISION_CONFLICT"): + await bm_write( + identifier="notes/guarded.md", + content="stale third revision", + overwrite=True, + expected_checksum=read["checksum"], + project=test_project.name, + ) diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 994f2d7fa..9681b96bc 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -146,22 +146,6 @@ "project", "project_id", ], - "edit_note": [ - "identifier", - "operation", - "content", - "project", - "workspace", - "project_id", - "section", - "find_text", - "expected_replacements", - "replace_subsections", - "metadata", - "expected_checksum", - "output_format", - ], - "fetch": ["id"], "find": [ "path", "name", @@ -183,22 +167,8 @@ "context_lines", "max_matches", ], - "list_workspaces": ["output_format"], "ls": ["path", "page", "page_size", "project", "project_id"], "man": ["page", "query", "project", "project_id"], - "read_note": [ - "identifier", - "project", - "project_id", - "page", - "page_size", - "output_format", - "include_frontmatter", - "start_line", - "end_line", - "include_content", - ], - "search": ["query"], "search_notes": [ "query", "project", @@ -242,14 +212,7 @@ "bm_project": {"readOnlyHint": False, "destructiveHint": False}, "bm_schema": {"readOnlyHint": True, "destructiveHint": False}, "bm_man": {"readOnlyHint": True, "destructiveHint": False}, - "fetch": {"readOnlyHint": True, "destructiveHint": False}, - "list_workspaces": {"readOnlyHint": True, "destructiveHint": False}, - "read_note": {"readOnlyHint": True, "destructiveHint": False}, - "search": {"readOnlyHint": True, "destructiveHint": False}, "search_notes": {"readOnlyHint": True, "destructiveHint": False}, - # edit_note's find_replace/replace_section overwrite existing content, so it is - # destructive even though append/prepend are additive. - "edit_note": {"readOnlyHint": False, "destructiveHint": True}, } # The MCP-UI tools are disabled in tools/__init__.py but register onto the shared @@ -297,15 +260,10 @@ "bm_schema": tools.bm_schema, "bm_man": tools.bm_man, "cat": tools.cat, - "edit_note": tools.edit_note, - "fetch": tools.fetch, "find": tools.find, "grep": tools.grep, - "list_workspaces": tools.list_workspaces, "ls": tools.ls, "man": tools.man, - "read_note": tools.read_note, - "search": tools.search, "search_notes": tools.search_notes, "tail": tools.tail, } diff --git a/tests/mcp/test_tool_edit_note.py b/tests/mcp/test_tool_edit_note.py deleted file mode 100644 index b819861c9..000000000 --- a/tests/mcp/test_tool_edit_note.py +++ /dev/null @@ -1,2109 +0,0 @@ -"""Tests for the edit_note MCP tool.""" - -import json -from pathlib import Path -from unittest.mock import patch - -import httpx -import pytest -from fastmcp.exceptions import ToolError - -from basic_memory.mcp.clients import KnowledgeClient -from basic_memory.mcp.tools.edit_note import ( - _resolve_after_disk_recovery, - _revision_conflict_detail, - edit_note, -) -from basic_memory.mcp.tools.read_note import read_note -from basic_memory.mcp.tools import bm_write -from basic_memory.schemas.v2.entity import EntityResolveResponse - - -def test_edit_note_workspace_project_route_helper(): - """workspace/project routing should be explicit and deterministic.""" - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - - assert ( - edit_note_module._compose_workspace_project_route( - workspace=None, - project="docs/setup", - project_id=None, - ) - == "docs/setup" - ) - assert ( - edit_note_module._compose_workspace_project_route( - workspace="docs", - project="setup", - project_id=None, - ) - == "docs/setup" - ) - - -@pytest.mark.parametrize( - ("route_kwargs", "message"), - [ - ( - {"workspace": " ", "project": "setup", "project_id": None}, - "workspace must not be empty", - ), - ( - {"workspace": "docs/setup", "project": "setup", "project_id": None}, - "workspace must be a single workspace", - ), - ( - {"workspace": "docs", "project": "setup", "project_id": "project-id"}, - "workspace cannot be combined with project_id", - ), - ( - {"workspace": "docs", "project": None, "project_id": None}, - "workspace requires an explicit project", - ), - ( - {"workspace": "docs", "project": "setup/install", "project_id": None}, - "not both", - ), - ], -) -def test_edit_note_workspace_project_route_helper_rejects_invalid_inputs( - route_kwargs, - message, -): - """Ambiguous workspace/project argument combinations should fail before routing.""" - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - - with pytest.raises(ValueError, match=message): - edit_note_module._compose_workspace_project_route(**route_kwargs) - - -@pytest.mark.asyncio -async def test_edit_note_append_operation(client, test_project): - """Test appending content to an existing note.""" - # Create initial note - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test Note\nOriginal content here.", - ) - - # Append content - result = await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="append", - content="\n## New Section\nAppended content here.", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert f"project: {test_project.name}" in result - assert "file_path: test/Test Note.md" in result - assert f"permalink: {test_project.name}/test/test-note" in result - assert "Added 3 lines to end of note" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_prepend_operation(client, test_project): - """Test prepending content to an existing note.""" - # Create initial note - await bm_write( - project=test_project.name, - title="Meeting Notes", - directory="meetings", - content="# Meeting Notes\nExisting content.", - ) - - # Prepend content - result = await edit_note( - project=test_project.name, - identifier="meetings/meeting-notes", - operation="prepend", - content="## 2025-05-25 Update\nNew meeting notes.\n", - ) - - assert isinstance(result, str) - assert "Edited note (prepend)" in result - assert f"project: {test_project.name}" in result - assert "file_path: meetings/Meeting Notes.md" in result - assert f"permalink: {test_project.name}/meetings/meeting-notes" in result - assert "Added 3 lines to beginning of note" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_operation(client, test_project): - """Test find and replace operation.""" - # Create initial note with version info - await bm_write( - project=test_project.name, - title="Config Document", - directory="config", - content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.", - ) - - # Replace version - expecting 2 replacements - result = await edit_note( - project=test_project.name, - identifier="config/config-document", - operation="find_replace", - content="v0.13.0", - find_text="v0.12.0", - expected_replacements=2, - ) - - assert isinstance(result, str) - assert "Edited note (find_replace)" in result - assert f"project: {test_project.name}" in result - assert "file_path: config/Config Document.md" in result - assert "operation: Find and replace operation completed" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_replace_section_operation(client, test_project): - """Test replacing content under a specific section.""" - # Create initial note with sections - await bm_write( - project=test_project.name, - title="API Specification", - directory="specs", - content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.", - ) - - # Replace implementation section - result = await edit_note( - project=test_project.name, - identifier="specs/api-specification", - operation="replace_section", - content="New implementation approach using FastAPI.\nImproved error handling.\n", - section="## Implementation", - ) - - assert isinstance(result, str) - assert "Edited note (replace_section)" in result - assert f"project: {test_project.name}" in result - assert "file_path: specs/API Specification.md" in result - assert "Replaced content under section '## Implementation'" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_replace_section_consumes_subsections_by_default(client, test_project): - """replace_section is level-aware by default: h3 subsections go with their h2 (#1012).""" - await bm_write( - project=test_project.name, - title="Level Aware Spec", - directory="specs", - content=( - "# Spec\n\n" - "## Implementation\n" - "Old details.\n\n" - "### Internals\n" - "Old internals.\n\n" - "## Testing\n" - "Test info here." - ), - ) - - result = await edit_note( - project=test_project.name, - identifier="specs/level-aware-spec", - operation="replace_section", - content="New details.\n\n## Rollout\nShip behind a flag.\n", - section="## Implementation", - ) - - assert isinstance(result, str) - assert "Edited note (replace_section)" in result - - # The h2 section was replaced through the next h2, subsections included, and the - # replacement's new h2 caused no duplicated re-emitted content - content = await read_note("specs/level-aware-spec", project=test_project.name) - assert "New details." in content - assert "## Rollout" in content - assert "### Internals" not in content - assert "Old internals." not in content - assert "Test info here." in content # next h2 section untouched - - -@pytest.mark.asyncio -async def test_edit_note_replace_section_opt_out_preserves_subsections(client, test_project): - """replace_subsections=False keeps h3 subsections, replacing only the immediate body.""" - await bm_write( - project=test_project.name, - title="Opt Out Spec", - directory="specs", - content=( - "# Spec\n\n" - "## Implementation\n" - "Old details.\n\n" - "### Internals\n" - "Internals stay.\n\n" - "## Testing\n" - "Test info here." - ), - ) - - result = await edit_note( - project=test_project.name, - identifier="specs/opt-out-spec", - operation="replace_section", - content="New details.\n", - section="## Implementation", - replace_subsections=False, - ) - - assert isinstance(result, str) - assert "Edited note (replace_section)" in result - - content = await read_note("specs/opt-out-spec", project=test_project.name) - assert "New details." in content - assert "Old details." not in content - assert "### Internals" in content # subsections preserved by the opt-out - assert "Internals stay." in content - assert "Test info here." in content - - -@pytest.mark.asyncio -async def test_edit_note_nonexistent_note_find_replace(client, test_project): - """Test find_replace on a note that doesn't exist - should return helpful guidance.""" - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="nonexistent/note", - operation="find_replace", - content="replacement", - find_text="old text", - ) - - result = str(exc_info.value) - assert "# Edit Failed" in result - assert "bm_search" in result # Should suggest searching - assert "bm_write" in result # Public bm_edit does not create missing notes - - -@pytest.mark.asyncio -async def test_edit_note_nonexistent_note_replace_section(client, test_project): - """Test replace_section on a note that doesn't exist - should return helpful guidance.""" - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="nonexistent/note", - operation="replace_section", - content="new section content", - section="## Missing Section", - ) - - result = str(exc_info.value) - assert "# Edit Failed" in result - assert "bm_search" in result # Should suggest searching - - -@pytest.mark.asyncio -async def test_edit_note_append_creates_note_if_not_found(client, test_project): - """append to a non-existent note should create it automatically.""" - result = await edit_note( - project=test_project.name, - identifier="auto-created-note", - operation="append", - content="# New Note\n\nCreated via append.", - ) - - assert isinstance(result, str) - assert "Created note (append)" in result - assert "fileCreated: true" in result - assert f"project: {test_project.name}" in result - - -@pytest.mark.asyncio -async def test_edit_note_prepend_creates_note_if_not_found(client, test_project): - """prepend to a non-existent note should create it automatically.""" - result = await edit_note( - project=test_project.name, - identifier="auto-created-prepend", - operation="prepend", - content="# Prepended Note\n\nCreated via prepend.", - ) - - assert isinstance(result, str) - assert "Created note (prepend)" in result - assert "fileCreated: true" in result - assert f"project: {test_project.name}" in result - - -@pytest.mark.asyncio -async def test_edit_note_append_creates_with_directory_from_identifier(client, test_project): - """Identifier 'conversations/my-note' should create in conversations/ directory.""" - result = await edit_note( - project=test_project.name, - identifier="conversations/my-note", - operation="append", - content="# My Note\n\nCreated in conversations directory.", - ) - - assert isinstance(result, str) - assert "Created note (append)" in result - assert "fileCreated: true" in result - assert "conversations/" in result - - -@pytest.mark.asyncio -async def test_edit_note_append_creates_at_root_when_no_directory(client, test_project): - """Identifier 'my-note' (no slash) should create at project root.""" - result = await edit_note( - project=test_project.name, - identifier="root-level-note", - operation="append", - content="# Root Note\n\nCreated at root.", - ) - - assert isinstance(result, str) - assert "Created note (append)" in result - assert "fileCreated: true" in result - - -@pytest.mark.asyncio -async def test_edit_note_append_creates_json_format(client, test_project): - """JSON output should include fileCreated: true when note is auto-created.""" - result = await edit_note( - project=test_project.name, - identifier="json-auto-create", - operation="append", - content="# JSON Test\n\nAuto-created.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is True - assert result["title"] is not None - assert result["operation"] == "append" - - -@pytest.mark.asyncio -async def test_edit_note_append_strips_project_prefix_on_autocreate(client, test_project): - """Project-prefixed identifiers must not create a nested project directory.""" - identifier = f"{test_project.permalink}/scratch/repro/note-c" - result = await edit_note( - project=test_project.name, - identifier=identifier, - operation="append", - content="Disposable reproduction note.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is True - assert result["file_path"] == "scratch/repro/note-c.md" - assert (Path(test_project.path) / "scratch" / "repro" / "note-c.md").exists() - assert not (Path(test_project.path) / test_project.permalink / "scratch").exists() - - -@pytest.mark.asyncio -async def test_edit_note_append_strips_project_prefix_memory_url_on_autocreate( - client, test_project -): - """memory:// URLs that include the active project create at the relative path.""" - identifier = f"memory://{test_project.permalink}/scratch/repro/note-mem" - result = await edit_note( - project=test_project.name, - identifier=identifier, - operation="append", - content="Disposable memory-url reproduction note.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is True - assert result["file_path"] == "scratch/repro/note-mem.md" - assert (Path(test_project.path) / "scratch" / "repro" / "note-mem.md").exists() - assert not (Path(test_project.path) / test_project.permalink / "scratch").exists() - - -@pytest.mark.asyncio -async def test_edit_note_memory_url_unresolved_project_never_autocreates(client, test_project): - """A failed memory URL route must not create a phantom note in the active project.""" - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="memory://missing-project/notes/phantom-note", - operation="append", - content="# Phantom\n\nThis must not be created.", - ) - - result = str(exc_info.value) - assert "# Edit Failed - Unresolved Project Route" in result - assert "No note was edited or created" in result - assert f"active project `{test_project.name}`" in result - assert not (Path(test_project.path) / "missing-project" / "notes" / "phantom-note.md").exists() - - -@pytest.mark.asyncio -async def test_edit_note_memory_url_unresolved_project_json_error(client, test_project): - """JSON mode reports an unresolved route without creating a file.""" - # A failed edit is a tool error (isError), in JSON mode its message is the structured result. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="memory://missing-project/notes/phantom-json-note", - operation="prepend", - content="# Phantom JSON", - output_format="json", - ) - - result = json.loads(str(exc_info.value)) - assert result["error"] == "UNRESOLVED_PROJECT_ROUTE" - assert result["fileCreated"] is False - assert result["projectRoute"] == "missing-project" - assert not ( - Path(test_project.path) / "missing-project" / "notes" / "phantom-json-note.md" - ).exists() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("output_format", ["text", "json"]) -async def test_edit_note_cross_project_resolution_stops_before_patch( - monkeypatch, - client, - test_project, - output_format, -): - """A sibling-project match should return a clean retry instead of leaking an entity ID.""" - target_project_id = "22222222-2222-2222-2222-222222222222" - target_entity_id = "33333333-3333-3333-3333-333333333333" - - async def resolve_in_sibling(self, identifier, *, strict=False): - assert strict is True - return EntityResolveResponse( - external_id=target_entity_id, - entity_id=42, - project_external_id=target_project_id, - permalink="sibling-project/notes/cross-project-note", - file_path="notes/Cross Project Note.md", - title="Cross Project Note", - resolution_method="search", - ) - - async def fail_patch(*args, **kwargs): # pragma: no cover - raise AssertionError("cross-project matches must stop before patching") - - monkeypatch.setattr(KnowledgeClient, "resolve_entity_response", resolve_in_sibling) - monkeypatch.setattr(KnowledgeClient, "patch_entity", fail_patch) - - with pytest.raises(ToolError) as refusal: - await edit_note( - project=test_project.name, - identifier="sibling-project::Cross Project Note", - operation="append", - content="\nMust not be appended.", - output_format=output_format, - ) - - if output_format == "json": - result = json.loads(str(refusal.value)) - assert result["error"] == "CROSS_PROJECT_ENTITY" - assert result["fileCreated"] is False - assert result["project"] == test_project.name - assert result["targetProjectId"] == target_project_id - assert target_entity_id not in result.values() - else: - result = str(refusal.value) - assert "# Edit Failed - Note Not Found In This Project" in result - assert f"selected project `{test_project.name}`" in result - assert f'project="{target_project_id}"' in result - assert target_entity_id not in result - - -@pytest.mark.asyncio -async def test_edit_note_existing_note_json_includes_file_created_false(client, test_project): - """JSON output for editing an existing note should include fileCreated: false.""" - # Create the note first - await bm_write( - project=test_project.name, - title="Existing JSON Note", - directory="test", - content="# Existing Note\nOriginal content.", - ) - - result = await edit_note( - project=test_project.name, - identifier="test/existing-json-note", - operation="append", - content="\nAppended content.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is False - assert result["title"] == "Existing JSON Note" - assert result["operation"] == "append" - - -@pytest.mark.asyncio -async def test_edit_note_invalid_operation(client, test_project): - """Test using an invalid operation.""" - # Create a note first - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test\nContent here.", - ) - - with pytest.raises(ValueError) as exc_info: - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="invalid_op", - content="Some content", - ) - - assert "Invalid operation 'invalid_op'" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_missing_find_text(client, test_project): - """Test find_replace operation without find_text parameter.""" - # Create a note first - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test\nContent here.", - ) - - with pytest.raises(ValueError) as exc_info: - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="find_replace", - content="replacement", - ) - - assert "find_text parameter is required for find_replace operation" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_edit_note_replace_section_missing_section(client, test_project): - """Test replace_section operation without section parameter.""" - # Create a note first - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test\nContent here.", - ) - - with pytest.raises(ValueError) as exc_info: - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="replace_section", - content="new content", - ) - - assert "section parameter is required for section-based operations" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_edit_note_replace_section_nonexistent_section(client, test_project): - """A missing section returns an error and leaves the note unchanged.""" - # Create initial note without the target section - await bm_write( - project=test_project.name, - title="Document", - directory="docs", - content="# Document\n\n## Existing Section\nSome content here.", - ) - - path = Path(test_project.path) / "docs/Document.md" - original = path.read_bytes() - - # Try to replace non-existent section - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="docs/document", - operation="replace_section", - content="New section content here.\n", - section="## New Section", - ) - - result = str(exc_info.value) - assert "# Edit Failed" in result - assert "Section '## New Section' not found" in result - assert "exact heading" in result - assert "Use append" in result - assert path.read_bytes() == original - - -@pytest.mark.asyncio -async def test_edit_note_with_observations_and_relations(client, test_project): - """Test editing a note that contains observations and relations.""" - # Create note with semantic content - await bm_write( - project=test_project.name, - title="Feature Spec", - directory="features", - content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.", - ) - - # Append more semantic content - result = await edit_note( - project=test_project.name, - identifier="features/feature-spec", - operation="append", - content="\n## Updates\n\n- [implementation] Added new feature #development\n- relates_to [[User Guide]]", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert "## Observations" in result - assert "## Relations" in result - - -@pytest.mark.asyncio -async def test_edit_note_identifier_variations(client, test_project): - """Test that various identifier formats work.""" - # Create a note - await bm_write( - project=test_project.name, - title="Test Document", - directory="docs", - content="# Test Document\nOriginal content.", - ) - - # Test different identifier formats - identifiers_to_test = [ - "docs/test-document", # permalink - "Test Document", # title - "docs/Test Document", # folder/title - ] - - for identifier in identifiers_to_test: - result = await edit_note( - project=test_project.name, - identifier=identifier, - operation="append", - content=f"\n## Update via {identifier}", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert f"project: {test_project.name}" in result - assert "file_path: docs/Test Document.md" in result - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_no_matches(client, test_project): - """Test find_replace when the find_text doesn't exist - should return error.""" - # Create initial note - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test Note\nSome content here.", - ) - - # Try to replace text that doesn't exist - should fail with default expected_replacements=1 - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="find_replace", - content="replacement", - find_text="nonexistent_text", - ) - - result = str(exc_info.value) - assert "# Edit Failed - Text Not Found" in result - assert "bm_read" in result # Should suggest reading the note first - assert "Alternative approaches" in result # Should suggest alternatives - - -@pytest.mark.asyncio -async def test_edit_note_empty_content_operations(client, test_project): - """Test operations with empty content.""" - # Create initial note - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test Note\nOriginal content.", - ) - - # Test append with empty content - result = await edit_note( - project=test_project.name, identifier="test/test-note", operation="append", content="" - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - # Should still work, just adding empty content - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_wrong_count(client, test_project): - """Test find_replace when replacement count doesn't match expected.""" - # Create initial note with version info - await bm_write( - project=test_project.name, - title="Config Document", - directory="config", - content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.", - ) - - # Try to replace expecting 1 occurrence, but there are actually 2 - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="config/config-document", - operation="find_replace", - content="v0.13.0", - find_text="v0.12.0", - expected_replacements=1, # Wrong! There are actually 2 occurrences - ) - - result = str(exc_info.value) - assert "# Edit Failed - Wrong Replacement Count" in result - assert "Expected 1 occurrences" in result - assert "but found 2" in result - assert "Update expected_replacements" in result # Should suggest the fix - assert "expected_replacements=2" in result # Should suggest the exact fix - - -@pytest.mark.asyncio -async def test_edit_note_replace_section_multiple_sections(client, test_project): - """Test replace_section with multiple sections having same header - should return helpful error.""" - # Create note with duplicate section headers - await bm_write( - project=test_project.name, - title="Sample Note", - directory="docs", - content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance", - ) - - # Try to replace section when multiple exist - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="docs/sample-note", - operation="replace_section", - content="New content", - section="## Section 1", - ) - - result = str(exc_info.value) - assert "# Edit Failed - Duplicate Section Headers" in result - assert "Multiple sections found" in result - assert "bm_read" in result # Should suggest reading the note first - assert "Make headers unique" in result # Should suggest making headers unique - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_empty_find_text(client, test_project): - """Test find_replace with empty/whitespace find_text - should return helpful error.""" - # Create initial note - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test Note\nSome content here.", - ) - - # Try with whitespace-only find_text - this should be caught by service validation - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="find_replace", - content="replacement", - find_text=" ", # whitespace only - ) - - result = str(exc_info.value) - assert "# Edit Failed" in result - # Should contain helpful guidance about the error - - -@pytest.mark.asyncio -async def test_edit_note_append_with_null_optional_fields(client, test_project): - """Regression test: MCP clients may send explicit null for unused optional fields. - - When an MCP client sends find_text=None, section=None, expected_replacements=None - for an append operation, the tool should accept them without validation errors. - """ - # Create initial note - await bm_write( - project=test_project.name, - title="Null Fields Test", - directory="test", - content="# Null Fields Test\nOriginal content.", - ) - - # Call edit_note with explicit None for all optional fields (simulates MCP null) - result = await edit_note( - project=test_project.name, - identifier="test/null-fields-test", - operation="append", - content="\nAppended content.", - find_text=None, - section=None, - expected_replacements=None, - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert f"project: {test_project.name}" in result - assert "file_path: test/Null Fields Test.md" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, test_project): - """Test that editing a note preserves the permalink when frontmatter doesn't contain one. - - This is a regression test for issue #170 where edit_note would fail with a validation error - because the permalink was being set to None when the markdown file didn't have a permalink - in its frontmatter. - """ - # Create initial note - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test Note\nOriginal content here.", - ) - - # Verify the note was created with a permalink - first_result = await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="append", - content="\nFirst edit.", - ) - - assert isinstance(first_result, str) - assert f"permalink: {test_project.name}/test/test-note" in first_result - - # Perform another edit - this should preserve the permalink even if the - # file doesn't have a permalink in its frontmatter - second_result = await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="append", - content="\nSecond edit.", - ) - - assert isinstance(second_result, str) - assert "Edited note (append)" in second_result - assert f"project: {test_project.name}" in second_result - assert f"permalink: {test_project.name}/test/test-note" in second_result - assert f"[Project: '{test_project.name}']" in second_result - # The edit should succeed without validation errors - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_rejects_fuzzy_match(client, test_project): - """find_replace must reject nonexistent identifiers, not fuzzy-match to a similar note.""" - # Create two notes that could be fuzzy-matched - await bm_write( - project=test_project.name, - title="Routing Test A", - directory="test", - content="# Routing Test A\nContent A.", - ) - await bm_write( - project=test_project.name, - title="Routing Test B", - directory="test", - content="# Routing Test B\nContent B.", - ) - - # Attempt to edit a nonexistent note — should error, not silently edit A or B - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="Routing Test NONEXISTENT", - operation="find_replace", - content="replaced", - find_text="Content", - ) - - result = str(exc_info.value) - assert "# Edit Failed" in result - - # Verify neither A nor B was modified - content_a = await read_note("Routing Test A", project=test_project.name) - assert "Content A" in content_a - content_b = await read_note("Routing Test B", project=test_project.name) - assert "Content B" in content_b - - -@pytest.mark.asyncio -async def test_edit_note_append_autocreate_not_fuzzy_match(client, test_project): - """append to a nonexistent note should auto-create it, not fuzzy-match an existing note.""" - await bm_write( - project=test_project.name, - title="Existing Note Alpha", - directory="test", - content="# Existing Note Alpha\nOriginal content.", - ) - - # Append to a nonexistent note — should create a new note, not edit "Existing Note Alpha" - result = await edit_note( - project=test_project.name, - identifier="Existing Note ZZZZZ", - operation="append", - content="# New Note\nBrand new content.", - ) - - assert isinstance(result, str) - assert "Created note (append)" in result - assert "fileCreated: true" in result - - # Verify original note was NOT modified - content = await read_note("Existing Note Alpha", project=test_project.name) - assert "Original content" in content - assert "Brand new content" not in content - - -@pytest.mark.asyncio -async def test_edit_note_insert_before_section_operation(client, test_project): - """Test inserting content before a section heading.""" - # Create initial note with sections - await bm_write( - project=test_project.name, - title="Insert Before Doc", - directory="docs", - content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.", - ) - - result = await edit_note( - project=test_project.name, - identifier="docs/insert-before-doc", - operation="insert_before_section", - content="--- inserted divider ---", - section="## Details", - ) - - assert isinstance(result, str) - assert "Edited note (insert_before_section)" in result - assert f"project: {test_project.name}" in result - assert "Inserted content before section '## Details'" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_insert_after_section_operation(client, test_project): - """Test inserting content after a section heading.""" - # Create initial note with sections - await bm_write( - project=test_project.name, - title="Insert After Doc", - directory="docs", - content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.", - ) - - result = await edit_note( - project=test_project.name, - identifier="docs/insert-after-doc", - operation="insert_after_section", - content="Inserted after overview heading", - section="## Overview", - ) - - assert isinstance(result, str) - assert "Edited note (insert_after_section)" in result - assert f"project: {test_project.name}" in result - assert "Inserted content after section '## Overview'" in result - assert f"[Project: '{test_project.name}']" in result - - -@pytest.mark.asyncio -async def test_edit_note_insert_before_section_missing_section(client, test_project): - """Test insert_before_section without section parameter raises ValueError.""" - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test\nContent here.", - ) - - with pytest.raises(ValueError, match="section parameter is required"): - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="insert_before_section", - content="new content", - ) - - -@pytest.mark.asyncio -async def test_edit_note_insert_before_section_not_found(client, test_project): - """Test insert_before_section when section doesn't exist returns error.""" - await bm_write( - project=test_project.name, - title="Test Note", - directory="test", - content="# Test\n\n## Existing\nContent here.", - ) - - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="test/test-note", - operation="insert_before_section", - content="new content", - section="## Nonexistent", - ) - - result = str(exc_info.value) - 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 bm_write( - 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_workspace_qualified_memory_url_keeps_complete_permalink( - client, - test_project, -): - from basic_memory.workspace_context import workspace_permalink_context - - expected_permalink = f"team-paul/{test_project.name}/team/tool-edit-note" - - with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): - await bm_write( - project=test_project.name, - title="Tool Edit Note", - directory="team", - content="# Tool Edit Note\nOriginal content.", - ) - - result = await edit_note( - project=test_project.name, - identifier=f"memory://{expected_permalink}", - operation="append", - content="\nAppended in team workspace.", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert f"permalink: {expected_permalink}" in result - - -@pytest.mark.asyncio -async def test_edit_note_workspace_qualified_plain_permalink_requires_explicit_route( - monkeypatch, - test_project, -): - """Plain workspace-qualified write identifiers should stop before mutating.""" - import importlib - from contextlib import asynccontextmanager - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - workspace_slug = "team-acme" - qualified_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note" - detected_identifiers: list[str] = [] - - async def detect_workspace_project(identifier, config, context=None): - detected_identifiers.append(identifier) - return f"{workspace_slug}/{test_project.name}" - - @asynccontextmanager - async def fail_if_called(*args, **kwargs): - raise AssertionError("ambiguous plain identifiers should not select a project client") - yield - - monkeypatch.setattr( - edit_note_module, - "_workspace_identifier_discovery_available", - lambda identifier, config: True, - ) - monkeypatch.setattr( - edit_note_module, - "detect_project_from_workspace_identifier_prefix", - detect_workspace_project, - raising=False, - ) - monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called) - - with pytest.raises(ToolError) as refusal: - await edit_note( - identifier=qualified_identifier, - operation="append", - content="\nAppended via plain workspace-qualified permalink.", - project=None, - ) - - assert detected_identifiers == [qualified_identifier] - result = str(refusal.value) - assert "# Edit Failed - Ambiguous Identifier" in result - assert f"`{qualified_identifier}` could refer to a local note path" in result - assert f'project="{workspace_slug}/{test_project.name}"' in result - assert f"memory://{qualified_identifier}" in result - - -@pytest.mark.asyncio -async def test_edit_note_workspace_qualified_plain_permalink_json_error( - monkeypatch, - test_project, -): - """Ambiguous plain write identifiers should stay machine-readable in JSON mode.""" - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - workspace_slug = "team-acme" - qualified_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note" - - async def detect_workspace_project(identifier, config, context=None): - assert identifier == qualified_identifier - return f"{workspace_slug}/{test_project.name}" - - monkeypatch.setattr( - edit_note_module, - "_workspace_identifier_discovery_available", - lambda identifier, config: True, - ) - monkeypatch.setattr( - edit_note_module, - "detect_project_from_workspace_identifier_prefix", - detect_workspace_project, - raising=False, - ) - - with pytest.raises(ToolError) as refusal: - await edit_note( - identifier=qualified_identifier, - operation="append", - content="\nAppended via plain workspace-qualified permalink.", - output_format="json", - project=None, - ) - - result = json.loads(str(refusal.value)) - assert result["error"] == "AMBIGUOUS_IDENTIFIER" - assert result["project"] == f"{workspace_slug}/{test_project.name}" - assert result["fileCreated"] is False - - -@pytest.mark.asyncio -async def test_edit_note_ambiguous_namespace_identifier_returns_guidance( - monkeypatch, - test_project, -): - """Namespace-style workspace identifiers should still return ambiguity guidance.""" - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - workspace_slug = "team-acme" - identifier = f"{workspace_slug}::{test_project.name}/team/plain-edit-note" - normalized_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note" - - async def detect_workspace_project(raw_identifier, config, context=None): - assert raw_identifier == identifier - return f"{workspace_slug}/{test_project.name}" - - monkeypatch.setattr( - edit_note_module, - "_workspace_identifier_discovery_available", - lambda identifier, config: True, - ) - monkeypatch.setattr( - edit_note_module, - "detect_project_from_workspace_identifier_prefix", - detect_workspace_project, - raising=False, - ) - - with pytest.raises(ToolError) as refusal: - await edit_note( - identifier=identifier, - operation="append", - content="\nAppended via namespace-style plain identifier.", - project=None, - ) - - result = str(refusal.value) - assert "# Edit Failed - Ambiguous Identifier" in result - assert f"`{identifier}` could refer to a local note path" in result - assert f"memory://{normalized_identifier}" in result - - -@pytest.mark.asyncio -async def test_edit_note_workspace_project_args_compose_explicit_route(monkeypatch): - """workspace plus project should route like project='workspace/project'.""" - from contextlib import asynccontextmanager - from types import SimpleNamespace - - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - captured_routes: list[tuple[str | None, str | None]] = [] - - @asynccontextmanager - async def fake_get_project_client(project, context=None, project_id=None): - captured_routes.append((project, project_id)) - yield object(), SimpleNamespace(name="setup") - - monkeypatch.setattr(edit_note_module, "get_project_client", fake_get_project_client) - - with pytest.raises(ValueError, match="Invalid operation"): - await edit_note( - identifier="install", - operation="invalid", - content="content", - workspace="docs", - project="setup", - ) - - assert captured_routes == [("docs/setup", None)] - - -@pytest.mark.asyncio -async def test_edit_note_plain_workspace_route_returns_guidance_with_local_config( - monkeypatch, - config_manager, - test_project, -): - """Mixed local+cloud configs should still stop ambiguous plain write routes.""" - from contextlib import asynccontextmanager - import importlib - - import basic_memory.mcp.project_context as project_context - from basic_memory.config import ProjectEntry - from basic_memory.mcp.project_context import ( - WorkspaceProjectEntry, - _build_workspace_project_index, - ) - from basic_memory.schemas.cloud import WorkspaceInfo - from basic_memory.schemas.project_info import ProjectItem - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - config = config_manager.load_config() - config.projects["hermes-memory"] = ProjectEntry( - path=str(config_manager.config_dir.parent / "hermes-memory") - ) - config.cloud_api_key = "bmc_test123" - config_manager.save_config(config) - - personal = WorkspaceInfo( - tenant_id="personal-tenant", - workspace_type="personal", - slug="personal", - name="Personal", - role="owner", - is_default=True, - ) - index = _build_workspace_project_index( - (personal,), - ( - WorkspaceProjectEntry( - workspace=personal, - project=ProjectItem( - id=1, - external_id="11111111-1111-1111-1111-111111111111", - name="main", - path="/tmp/main", - is_default=False, - ), - ), - ), - ) - - async def fake_index(context=None): - return index - - @asynccontextmanager - async def fail_if_called(*args, **kwargs): - raise AssertionError("ambiguous plain identifiers should not select a project client") - yield - - monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index) - monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False) - monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False) - monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False) - monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called) - - with pytest.raises(ToolError) as refusal: - await edit_note( - identifier="personal/main/team/plain-edit-note", - operation="append", - content="\nAppended via plain workspace-qualified permalink.", - project=None, - ) - - result = str(refusal.value) - assert "# Edit Failed - Ambiguous Identifier" in result - assert 'project="personal/main"' in result - - -@pytest.mark.asyncio -async def test_edit_note_three_segment_plain_path_stays_local_without_workspace_discovery( - monkeypatch, - client, - test_project, -): - """Three-segment local paths should stay on the active project without discovery.""" - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - - await bm_write( - project=test_project.name, - title="Local Three Segment", - directory="folder/subdir", - content="# Local Three Segment\nOriginal content.", - ) - - async def fail_if_called(*args, **kwargs): - raise AssertionError("local three-segment paths should not trigger workspace detection") - - monkeypatch.setattr( - edit_note_module, - "_workspace_identifier_discovery_available", - lambda identifier, config: False, - ) - monkeypatch.setattr( - edit_note_module, - "detect_project_from_workspace_identifier_prefix", - fail_if_called, - ) - - result = await edit_note( - identifier="folder/subdir/local-three-segment", - operation="append", - content="\nAppended locally.", - project=None, - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert f"project: {test_project.name}" in result - - updated = await read_note( - identifier="folder/subdir/local-three-segment", - project=test_project.name, - ) - assert "Appended locally." in updated - - -@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_memory_url_prefix") as mock_url, - patch( - "basic_memory.mcp.tools.edit_note.detect_project_from_workspace_identifier_prefix" - ) as mock_workspace, - ): - # 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_url.assert_not_called() - mock_workspace.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_memory_url_prefix") as mock_url, - patch( - "basic_memory.mcp.tools.edit_note.detect_project_from_workspace_identifier_prefix" - ) as mock_workspace, - ): - await edit_note( - identifier=f"memory://{test_project.name}/test/some-note", - operation="append", - content="content", - project=test_project.name, - ) - - mock_url.assert_not_called() - mock_workspace.assert_not_called() - - -@pytest.mark.asyncio -async def test_edit_note_skips_detection_when_project_id_provided( - monkeypatch, - client, - test_project, -): - """project_id is authoritative, so memory URL discovery must not run first.""" - await bm_write( - project=test_project.name, - title="Project ID Memory URL Edit", - directory="test", - content="# Project ID Memory URL Edit\nOriginal content.", - ) - - async def fail_if_called(*args, **kwargs): - raise AssertionError("project_id routing should bypass URL discovery") - - import importlib - - edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") - monkeypatch.setattr(edit_note_module, "detect_project_from_memory_url_prefix", fail_if_called) - monkeypatch.setattr( - edit_note_module, - "detect_project_from_workspace_identifier_prefix", - fail_if_called, - ) - - result = await edit_note( - identifier=f"memory://{test_project.name}/test/project-id-memory-url-edit", - project_id=test_project.external_id, - operation="append", - content="\nAppended via project_id.", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert f"project: {test_project.name}" in result - - -@pytest.mark.asyncio -async def test_edit_note_find_replace_recovers_file_on_disk_not_indexed(client, test_project): - """find_replace should index and edit a file written directly to disk (#581).""" - note_path = Path(test_project.path) / "notes" / "disk-note.md" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Disk Note\n\nstatus: draft\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier="notes/disk-note", - operation="find_replace", - content="status: final", - find_text="status: draft", - ) - - assert isinstance(result, str) - assert "Edited note (find_replace)" in result - assert "status: final" in note_path.read_text(encoding="utf-8") - - -@pytest.mark.asyncio -async def test_edit_note_recovers_identifier_with_md_extension(client, test_project): - """An identifier already ending in .md should recover via the exact file path (#581).""" - note_path = Path(test_project.path) / "notes" / "exact-path.md" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Exact Path\n\nversion: v1\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier="notes/exact-path.md", - operation="find_replace", - content="version: v2", - find_text="version: v1", - ) - - assert isinstance(result, str) - assert "Edited note (find_replace)" in result - assert "version: v2" in note_path.read_text(encoding="utf-8") - - -@pytest.mark.asyncio -async def test_edit_note_recovers_identifier_with_markdown_extension(client, test_project): - """A .markdown identifier must recover via the exact path, not '.markdown.md' (#581).""" - note_path = Path(test_project.path) / "notes" / "alt-suffix.markdown" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Alt Suffix\n\nstate: pending\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier="notes/alt-suffix.markdown", - operation="find_replace", - content="state: done", - find_text="state: pending", - ) - - assert isinstance(result, str) - assert "Edited note (find_replace)" in result - assert "state: done" in note_path.read_text(encoding="utf-8") - - -@pytest.mark.asyncio -async def test_edit_note_refuses_ignored_on_disk_file(client, test_project): - """An on-disk file matched by .gitignore must be refused, not shadowed by auto-create.""" - project_path = Path(test_project.path) - (project_path / ".gitignore").write_text("private/\n", encoding="utf-8") - note_path = project_path / "private" / "secret.md" - note_path.parent.mkdir(parents=True, exist_ok=True) - original_content = "# Secret\n\nGitignored content.\n" - note_path.write_text(original_content, encoding="utf-8") - - # A failed edit is a tool error (isError), and its message keeps the guidance. - with pytest.raises(ToolError) as exc_info: - await edit_note( - project=test_project.name, - identifier="private/secret", - operation="append", - content="\nShould never be written.", - ) - - result = str(exc_info.value) - assert "ignore rules" in result - assert "will not be edited" in result - assert "Edited note" not in result - assert "Created note" not in result - - # The ignored file is untouched and auto-create did not shadow it with a new entity - assert note_path.read_text(encoding="utf-8") == original_content - assert [entry.name for entry in (project_path / "private").iterdir()] == ["secret.md"] - - -@pytest.mark.asyncio -async def test_edit_note_append_recovers_file_on_disk_instead_of_autocreate(client, test_project): - """append to an unindexed on-disk file should edit it, not auto-create a replacement (#581).""" - note_path = Path(test_project.path) / "notes" / "disk-append.md" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Disk Append\n\nOriginal disk content.\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier="notes/disk-append", - operation="append", - content="\nAppended line.", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert "Created note" not in result - - final_content = note_path.read_text(encoding="utf-8") - assert "Original disk content." in final_content - assert "Appended line." in final_content - - -@pytest.mark.asyncio -async def test_edit_note_append_recovers_unindexed_file_below_the_project_prefix( - client, test_project -): - """Codex P1 on #1776: disk recovery probed only the prefixed path, missed the real - file, and the create that followed collided with it. Every reading is probed now, - so the note on disk is edited (#1775).""" - note_path = Path(test_project.path) / "notes" / "prefixed-disk.md" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Prefixed Disk\n\nOriginal disk content.\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier=f"{test_project.permalink}/notes/prefixed-disk", - operation="append", - content="\nAppended line.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is False - assert result["file_path"] == "notes/prefixed-disk.md" - assert "Appended line." in note_path.read_text(encoding="utf-8") - assert not (Path(test_project.path) / test_project.permalink).exists() - - -@pytest.mark.asyncio -async def test_edit_note_append_creates_inside_an_existing_folder_named_like_the_project( - client, test_project -): - """A real folder named like the project is evidence the caller meant it, even though - permalinks carry the project prefix and would otherwise strip it (#1775).""" - await bm_write( - project=test_project.name, - title="Anchor", - directory=f"{test_project.permalink}/logs", - content="# Anchor", - ) - - result = await edit_note( - project=test_project.name, - identifier=f"{test_project.permalink}/logs/today", - operation="append", - content="First entry.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is True - assert result["file_path"] == f"{test_project.permalink}/logs/today.md" - - -@pytest.mark.asyncio -async def test_edit_note_append_reads_folders_from_storage_not_the_index(client, test_project): - """Codex review on #1782: a folder whose notes exist only on disk, not yet indexed, - still holds files. Judging folders from the index would read it as empty, create - below the prefix instead, and leave a note no later reindex relocates (#1775).""" - on_disk = Path(test_project.path) / test_project.permalink / "logs" / "yesterday.md" - on_disk.parent.mkdir(parents=True) - on_disk.write_text("# Yesterday", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier=f"{test_project.permalink}/logs/today", - operation="append", - content="First entry.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["fileCreated"] is True - assert result["file_path"] == f"{test_project.permalink}/logs/today.md" - assert not (Path(test_project.path) / "logs").exists() - - -@pytest.mark.asyncio -async def test_edit_note_append_counts_an_accepted_note_not_yet_on_storage(client, test_project): - """Codex review on #1782: production accepts a write before materializing its file, - so a folder holding only an accepted note is not on storage yet. It still decides - placement, or the next note in it lands below the prefix for good (#1775).""" - anchor = await bm_write( - project=test_project.name, - title="Anchor", - directory=f"{test_project.permalink}/logs", - content="# Anchor", - output_format="json", - ) - assert isinstance(anchor, dict) - # Stand in for a materialization that has not run yet: the accepted note keeps its - # entity row, and storage holds no file in the folder. - (Path(test_project.path) / anchor["file_path"]).unlink() - - result = await edit_note( - project=test_project.name, - identifier=f"{test_project.permalink}/logs/today", - operation="append", - content="First entry.", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["file_path"] == f"{test_project.permalink}/logs/today.md" - - -@pytest.mark.asyncio -async def test_edit_note_append_refuses_when_both_folders_hold_notes(client, test_project): - """When the folder below the prefix and a folder named like the project both exist, - nothing says which one the caller meant, so append refuses instead of guessing and - names the spelling that picks each (#1775).""" - for directory in ("logs", f"{test_project.permalink}/logs"): - await bm_write( - project=test_project.name, - title="Anchor", - directory=directory, - content="# Anchor", - ) - - with pytest.raises(ToolError) as excinfo: - await edit_note( - project=test_project.name, - identifier=f"{test_project.permalink}/logs/today", - operation="append", - content="First entry.", - output_format="json", - ) - - refusal = json.loads(str(excinfo.value)) - assert refusal["error"] == "AMBIGUOUS_NOTE_PATH" - assert refusal["candidates"] == ["logs/today.md", f"{test_project.permalink}/logs/today.md"] - assert not (Path(test_project.path) / "logs" / "today.md").exists() - - -@pytest.mark.asyncio -async def test_edit_note_append_recovers_markdown_suffix_file_from_stem(client, test_project): - """A stem identifier for an on-disk .markdown file edits it, not auto-creates .md (#581). - - Recovery probes the identifier as-is, then '.md', then '.markdown'; without the - '.markdown' probe, append would auto-create 'notes/alt-stem.md' next to the real - file instead of editing it. - """ - note_path = Path(test_project.path) / "notes" / "alt-stem.markdown" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Alt Stem\n\nOriginal markdown-suffix content.\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier="notes/alt-stem", - operation="append", - content="\nAppended line.", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert "Created note" not in result - - final_content = note_path.read_text(encoding="utf-8") - assert "Original markdown-suffix content." in final_content - assert "Appended line." in final_content - # The real file was edited in place; no shadow .md entity was created beside it - assert [entry.name for entry in note_path.parent.iterdir()] == ["alt-stem.markdown"] - - -@pytest.mark.asyncio -async def test_edit_note_append_recovers_wrong_cased_identifier(client, test_project): - """A wrong-cased identifier edits the canonical on-disk file after recovery (#581). - - The index-file endpoint canonicalizes casing by matching real directory entries, - so syncing 'notes/Disk-Note.md' indexes 'notes/disk-note.md' identically on - case-sensitive (CI) and case-insensitive (macOS) filesystems — no filesystem - probe is needed here. The regression: the retry used to strictly re-resolve the - raw wrong-cased identifier, which can miss the just-indexed canonical entity; - the fix returns the entity identity straight from the index-file response. - """ - note_path = Path(test_project.path) / "notes" / "disk-note.md" - note_path.parent.mkdir(parents=True, exist_ok=True) - note_path.write_text("# Disk Note\n\nOriginal cased content.\n", encoding="utf-8") - - result = await edit_note( - project=test_project.name, - identifier="notes/Disk-Note", - operation="append", - content="\nAppended line.", - ) - - assert isinstance(result, str) - assert "Edited note (append)" in result - assert "Created note" not in result - - final_content = note_path.read_text(encoding="utf-8") - assert "Original cased content." in final_content - assert "Appended line." in final_content - # The canonical file was edited; no wrong-cased duplicate was created beside it - assert [entry.name for entry in note_path.parent.iterdir()] == ["disk-note.md"] - - -@pytest.mark.asyncio -async def test_resolve_after_disk_recovery_falls_back_to_strict_resolve(): - """Older servers that omit external_id from index-file trigger a strict re-resolve. - - The recovery path prefers the entity identity from the index-file response; when a - server predates that field, the only safe option is a strict re-resolve of the - raw identifier (which fails loudly on a miss instead of guessing). - """ - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/index-file"): - return httpx.Response( - 200, - json={ - "permalink": "notes/old-server-note", - "title": "Old Server Note", - "file_path": "notes/old-server-note.md", - "note_type": "note", - "content_type": "text/markdown", - "observations": [], - "relations": [], - "created_at": "2024-01-01T00:00:00", - "updated_at": "2024-01-01T00:00:00", - }, - ) - assert request.url.path.endswith("/resolve") - return httpx.Response(200, json={"external_id": "resolved-entity-uuid"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: - knowledge_client = KnowledgeClient(http_client, "project-external-id") - result = await _resolve_after_disk_recovery(knowledge_client, "notes/old-server-note") - - assert result == "resolved-entity-uuid" - - -@pytest.mark.asyncio -async def test_resolve_after_disk_recovery_propagates_unexpected_errors(): - """Server-side failures during disk recovery must not be masked as a not-found miss. - - Only 400/404 index-file rejections mean "nothing to recover"; a 500 (or auth - failure) would otherwise be swallowed and edit_note would continue into - auto-create with a misleading not-found error. - """ - - def server_error(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, json={"detail": "boom"}) - - transport = httpx.MockTransport(server_error) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: - knowledge_client = KnowledgeClient(http_client, "project-external-id") - with pytest.raises(ToolError, match="boom"): - await _resolve_after_disk_recovery(knowledge_client, "notes/unlucky-note") - - -@pytest.mark.asyncio -async def test_edit_note_append_traversal_identifier_is_blocked(client, test_project): - """A traversal identifier must be rejected by both disk recovery and auto-create.""" - with pytest.raises(ToolError) as refusal: - await edit_note( - project=test_project.name, - identifier="../escape-note", - operation="append", - content="should never be written", - ) - - result = str(refusal.value) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - assert not (Path(test_project.path).parent / "escape-note.md").exists() - - -@pytest.mark.asyncio -async def test_edit_note_append_traversal_identifier_json_error(client, test_project): - """JSON mode reports a structured security error for traversal identifiers.""" - with pytest.raises(ToolError) as refusal: - await edit_note( - project=test_project.name, - identifier="../escape-json-note", - operation="append", - content="should never be written", - output_format="json", - ) - - result = json.loads(str(refusal.value)) - assert result["error"] == "SECURITY_VALIDATION_ERROR" - assert result["fileCreated"] is False - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "identifier_below_prefix", - ["../../outside-project/secrets/note", "/tmp/outside-note", "notes/../../../escape"], -) -async def test_edit_note_append_refuses_prefixed_paths_that_escape_the_project( - client, test_project, identifier_below_prefix -): - """A project prefix in front of a traversal must not reach storage: placement - refuses before probing a folder, and nothing is recovered or created outside the - project (#1782 review).""" - outside = Path(test_project.path).parent / "outside-project" / "secrets" - outside.mkdir(parents=True, exist_ok=True) - (outside / "existing.md").write_text("# Existing", encoding="utf-8") - - with pytest.raises(ToolError) as refusal: - await edit_note( - project=test_project.name, - identifier=f"{test_project.permalink}/{identifier_below_prefix}", - operation="append", - content="should never be written", - output_format="json", - ) - - result = json.loads(str(refusal.value)) - assert result["error"] == "SECURITY_VALIDATION_ERROR" - assert result["fileCreated"] is False - assert sorted(path.name for path in outside.iterdir()) == ["existing.md"] - assert not Path("/tmp/outside-note.md").exists() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("file_checksum", [None, "c" * 64]) -async def test_edit_note_reports_the_accepted_db_checksum( - monkeypatch, app, test_project, file_checksum -): - """edit_note reports db_checksum, the value edit preconditions compare (#1586). - - The PATCH response is replaced to inject a not-yet-materialized file and a - drifted file checksum, which the inline test environment never produces. - """ - await bm_write( - project=test_project.name, - title="Deferred Edit Note", - directory="test", - content="# Deferred Edit Note\n\nOriginal body.", - ) - - db_checksum = "b" * 64 # a real, already-persisted SHA-256 hex digest - real_patch_entity = KnowledgeClient.patch_entity - - async def fake_patch_entity(self, entity_id, patch_data, *, base_checksum=None): - result = await real_patch_entity(self, entity_id, patch_data, base_checksum=base_checksum) - return result.model_copy( - update={"file_checksum": file_checksum, "db_checksum": db_checksum} - ) - - monkeypatch.setattr(KnowledgeClient, "patch_entity", fake_patch_entity) - - result = await edit_note( - project=test_project.name, - identifier="Deferred Edit Note", - operation="append", - content="\n\nAppended body.", - ) - - assert "checksum: unknown" not in result - assert f"checksum: {db_checksum[:8]}" in result - - -@pytest.mark.asyncio -async def test_edit_note_reports_unknown_outcome_when_no_response_arrives( - client, test_project, monkeypatch -): - """A PATCH that got no response may have committed; the error must say to read first.""" - await bm_write( - project=test_project.name, - title="Unknown Outcome", - directory="notes", - content="# Unknown Outcome\n\nBody.", - ) - - async def dropped_connection(self, entity_id, patch_data, *, base_checksum=None): - try: - raise httpx.ReadError("connection dropped") - except httpx.ReadError as error: - raise ToolError("Network error: the connection dropped") from error - - monkeypatch.setattr(KnowledgeClient, "patch_entity", dropped_connection) - - with pytest.raises(ToolError) as text_failure: - await edit_note( - project=test_project.name, - identifier="notes/unknown-outcome", - operation="append", - content="\nAppended.", - ) - assert "# Edit Outcome Unknown" in str(text_failure.value) - assert "may have been applied" in str(text_failure.value) - - with pytest.raises(ToolError) as json_failure: - await edit_note( - project=test_project.name, - identifier="notes/unknown-outcome", - operation="append", - content="\nAppended.", - output_format="json", - ) - payload = json.loads(str(json_failure.value)) - assert payload["error"] == "EDIT_OUTCOME_UNKNOWN" - assert "connection dropped" in payload["detail"] - # The suggested check names the routed project by id, not a reusable name. - assert f'project="{test_project.external_id}"' in payload["message"] - - -@pytest.mark.asyncio -async def test_edit_note_reports_a_4xx_as_a_refusal_not_an_unknown_outcome( - client, test_project, monkeypatch -): - """The API answered no, so nothing was written and the plain failure stands.""" - await bm_write( - project=test_project.name, - title="Refused Edit", - directory="notes", - content="# Refused Edit\n\nBody.", - ) - - async def conflict(self, entity_id, patch_data, *, base_checksum=None): - request = httpx.Request("PATCH", "http://test/entities") - response = httpx.Response(409, request=request) - try: - response.raise_for_status() - except httpx.HTTPStatusError as error: - raise ToolError("Note was modified concurrently") from error - - monkeypatch.setattr(KnowledgeClient, "patch_entity", conflict) - - with pytest.raises(ToolError) as refusal: - await edit_note( - project=test_project.name, - identifier="notes/refused-edit", - operation="append", - content="\nAppended.", - output_format="json", - ) - payload = json.loads(str(refusal.value)) - assert payload["error"] == "Note was modified concurrently" - - # Trigger: the caller sent expected_checksum, but the 409 is not the structured - # base-checksum conflict (no {"db_checksum": ...} detail). - # Outcome: it keeps the ordinary refusal instead of claiming a revision conflict. - with pytest.raises(ToolError) as guarded_refusal: - await edit_note( - project=test_project.name, - identifier="notes/refused-edit", - operation="append", - content="\nAppended.", - expected_checksum="a" * 64, - output_format="json", - ) - assert json.loads(str(guarded_refusal.value))["error"] == "Note was modified concurrently" - - -def test_revision_conflict_detail_only_matches_the_structured_checksum_409(): - """Only a 409 carrying the {"db_checksum": ...} detail is a revision conflict.""" - request = httpx.Request("PATCH", "http://test/entities") - - def tool_error_from(response: httpx.Response) -> ToolError: - try: - response.raise_for_status() - except httpx.HTTPStatusError as error: - try: - raise ToolError("refused") from error - except ToolError as tool_error: - return tool_error - raise AssertionError("expected an HTTP error status") # pragma: no cover - - structured = tool_error_from( - httpx.Response( - 409, - request=request, - json={"detail": {"message": "Note changed since your last sync", "db_checksum": "b"}}, - ) - ) - assert _revision_conflict_detail(structured) == { - "message": "Note changed since your last sync", - "db_checksum": "b", - } - - plain_conflict = tool_error_from( - httpx.Response(409, request=request, json={"detail": "ambiguous identifier"}) - ) - assert _revision_conflict_detail(plain_conflict) is None - - not_found = tool_error_from(httpx.Response(404, request=request, json={"detail": "gone"})) - assert _revision_conflict_detail(not_found) is None - - assert _revision_conflict_detail(ToolError("no HTTP cause")) is None diff --git a/tests/mcp/test_tool_guidance.py b/tests/mcp/test_tool_guidance.py index a1b2dc684..278615331 100644 --- a/tests/mcp/test_tool_guidance.py +++ b/tests/mcp/test_tool_guidance.py @@ -3,8 +3,7 @@ import pytest from fastmcp.exceptions import ToolError -from basic_memory.mcp.tools import bm_delete, bm_read, bm_write, edit_note -from basic_memory.mcp.tools.edit_note import _unknown_outcome_guidance +from basic_memory.mcp.tools import bm_delete, bm_edit, bm_read, bm_write @pytest.mark.asyncio @@ -15,9 +14,9 @@ async def test_refused_edit_points_to_bm_read(client, test_project, output_forma ) with pytest.raises(ToolError) as refused: - await edit_note( + await bm_edit( identifier="notes/guarded.md", - operation="append", + command="append", content="Must not land", expected_checksum="stale", output_format=output_format, @@ -29,7 +28,7 @@ async def test_refused_edit_points_to_bm_read(client, test_project, output_forma assert "bm_read(identifier=" in guidance assert "read_note(" not in guidance assert "project_id=" not in guidance - assert test_project.external_id in guidance + assert test_project.name in guidance content = await bm_read("notes/guarded.md", project=test_project.external_id) assert isinstance(content, str) assert "Original content" in content @@ -52,10 +51,3 @@ async def test_refused_delete_points_to_bm_read(client, test_project, output_for assert "bm_read(identifier=" in str(refused.value) assert "read_note(" not in str(refused.value) assert "Keep this note" in str(await bm_read("notes/guarded.md", project=test_project.name)) - - -def test_unknown_edit_outcome_points_to_bm_read_by_project_id() -> None: - guidance = _unknown_outcome_guidance("notes/guarded.md", "project-uuid") - assert 'bm_read(identifier="notes/guarded.md", project="project-uuid")' in guidance - assert "read_note(" not in guidance - assert "project_id=" not in guidance diff --git a/tests/mcp/test_tool_json_output_modes.py b/tests/mcp/test_tool_json_output_modes.py index 7ee408166..2c005abad 100644 --- a/tests/mcp/test_tool_json_output_modes.py +++ b/tests/mcp/test_tool_json_output_modes.py @@ -9,8 +9,6 @@ from basic_memory.mcp.tools import ( bm_project, bm_write, - edit_note, - read_note, ) @@ -42,80 +40,6 @@ async def test_write_note_text_and_json_modes(app, test_project): assert "checksum" in json_result -@pytest.mark.asyncio -async def test_read_note_text_and_json_modes(app, test_project, indexed_project): - await bm_write( - project=test_project.name, - title="Mode Read Note", - directory="mode-tests", - content="# Mode Read Note\n\nbody", - ) - - text_result = await read_note( - identifier="mode-tests/mode-read-note", - project=test_project.name, - output_format="text", - ) - assert isinstance(text_result, str) - assert "Mode Read Note" in text_result - - json_result = await read_note( - identifier="mode-tests/mode-read-note", - project=test_project.name, - output_format="json", - ) - assert isinstance(json_result, dict) - assert json_result["title"] == "Mode Read Note" - assert json_result["permalink"] - assert json_result["file_path"] - assert isinstance(json_result["content"], str) - assert "frontmatter" in json_result - - missing_json = await read_note( - identifier="mode-tests/missing-note", - project=test_project.name, - output_format="json", - ) - assert isinstance(missing_json, dict) - assert set(["title", "permalink", "file_path", "content", "frontmatter"]).issubset( - missing_json.keys() - ) - - -@pytest.mark.asyncio -async def test_edit_note_text_and_json_modes(app, test_project): - await bm_write( - project=test_project.name, - title="Mode Edit Note", - directory="mode-tests", - content="# Mode Edit Note\n\nstart", - ) - - text_result = await edit_note( - identifier="mode-tests/mode-edit-note", - operation="append", - content="\n\ntext-append", - project=test_project.name, - output_format="text", - ) - assert isinstance(text_result, str) - assert "Edited note" in text_result - - json_result = await edit_note( - identifier="mode-tests/mode-edit-note", - operation="append", - content="\n\njson-append", - project=test_project.name, - output_format="json", - ) - assert isinstance(json_result, dict) - assert json_result["title"] == "Mode Edit Note" - assert json_result["operation"] == "append" - assert json_result["permalink"] - assert json_result["file_path"] - assert "checksum" in json_result - - @pytest.mark.asyncio async def test_list_and_create_project_text_and_json_modes(app, test_project, tmp_path): list_text = await bm_project(command="list", output_format="text") diff --git a/tests/mcp/test_tool_read_note.py b/tests/mcp/test_tool_read_note.py deleted file mode 100644 index 6bc264b5b..000000000 --- a/tests/mcp/test_tool_read_note.py +++ /dev/null @@ -1,1559 +0,0 @@ -"""Tests for note tools that exercise the full stack with SQLite.""" - -import json -from contextlib import asynccontextmanager -from types import SimpleNamespace -from textwrap import dedent - -import pytest -from fastmcp import Client -from fastmcp.exceptions import ToolError -from httpx import HTTPStatusError, Request, Response - -from basic_memory import db -from basic_memory.mcp.tools import bm_write, read_note -from basic_memory.mcp.note_reads import format_note_metadata -from basic_memory.mcp.tools.read_note import _parse_opening_frontmatter -from tests.mcp.conftest import ContextState, ctx -from typing import override - -import implementation_mcp - -implementation_server = implementation_mcp.implementation_server - - -def lookup_miss() -> ToolError: - request = Request("POST", "http://test/knowledge/resolve") - response = Response(404, request=request) - error = ToolError("Note not found") - error.__cause__ = HTTPStatusError("Note not found", request=request, response=response) - return error - - -def test_parse_opening_frontmatter_handles_crlf(): - """JSON read_note output should strip frontmatter from Windows-written markdown.""" - body, frontmatter = _parse_opening_frontmatter( - "---\r\ntitle: Windows Note\r\ntype: note\r\n---\r\n\r\nBody text\r\n" - ) - - assert frontmatter == {"title": "Windows Note", "type": "note"} - assert body.strip() == "Body text" - - -@pytest.mark.asyncio -async def test_read_note_by_title(app, test_project): - """Test reading a note by its title.""" - # First create a note - await bm_write( - project=test_project.name, - title="Special Note", - directory="test", - content="Note content here", - ) - - # Should be able to read it by title - content = await read_note("Special Note", project=test_project.name) - assert "Note content here" in content - - -@pytest.mark.asyncio -async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch, app, test_project): - """Force direct resolve to fail so we exercise the title-search + fetch fallback path.""" - await bm_write( - project=test_project.name, - title="Fallback Title Note", - directory="test", - content="fallback content", - ) - - import importlib - from basic_memory.schemas.memory import memory_url_path - - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - direct_identifier = memory_url_path("Fallback Title Note") - - class SelectiveKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - # Fail on the direct identifier to force fallback to title search - if identifier == direct_identifier: - raise lookup_miss() - return await super().resolve_entity(identifier, strict=strict) - - monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient) - - content = await read_note("Fallback Title Note", project=test_project.name) - assert "fallback content" in content - - -@pytest.mark.asyncio -async def test_read_note_returns_related_results_when_text_search_finds_matches( - monkeypatch, app, test_project -): - """Exercise the related-results message when no exact note match exists.""" - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - - async def fake_search_notes_fn(*, query, search_type, **kwargs): - if search_type == "title": - return {"results": [], "current_page": 1, "page_size": 10} - - return { - "results": [ - { - "title": "Related One", - "permalink": "docs/related-one", - "content": "", - "type": "entity", - "score": 1.0, - "file_path": "docs/related-one.md", - }, - { - "title": "Related Two", - "permalink": "docs/related-two", - "content": "", - "type": "entity", - "score": 0.9, - "file_path": "docs/related-two.md", - }, - ], - "current_page": 1, - "page_size": 10, - } - - # Ensure direct resolution doesn't short-circuit the fallback logic. - class FailingKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - raise lookup_miss() - - monkeypatch.setattr(clients_mod, "KnowledgeClient", FailingKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn) - - result = await read_note("missing-note", project=test_project.name) - assert "No exact match for" in result - assert "## 1. Related One" in result - assert "## 2. Related Two" in result - - -@pytest.mark.asyncio -async def test_read_note_direct_match_returns_full_content_regardless_of_paging(app, test_project): - """page/page_size never chunk note content — a direct match returns the whole note.""" - content = "Line one of the note\nLine two of the note\nLine three of the note" - await bm_write( - project=test_project.name, - title="Paging Direct Note", - directory="test", - content=content, - ) - - result = await read_note( - "test/paging-direct-note", - project=test_project.name, - page=3, - page_size=1, - ) - - assert "Line one of the note" in result - assert "Line three of the note" in result - - -@pytest.mark.asyncio -async def test_read_note_rejects_non_positive_pagination(app, test_project): - """Fail fast on invalid pagination, matching search_notes/build_context.""" - with pytest.raises(ValueError, match="page must be >= 1"): - await read_note("any-note", project=test_project.name, page=0) - - with pytest.raises(ValueError, match="page_size must be >= 1"): - await read_note("any-note", project=test_project.name, page_size=0) - - -@pytest.mark.asyncio -async def test_read_note_forwards_pagination_to_fallback_search(monkeypatch, app, test_project): - """page/page_size must reach the server-side fallback search, not be swallowed.""" - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - - captured_pages: list[tuple[str, int, int]] = [] - - async def fake_search_notes_fn(*, query, search_type, page, page_size, **kwargs): - captured_pages.append((search_type, page, page_size)) - return {"results": [], "current_page": page, "page_size": page_size} - - class FailingKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - raise lookup_miss() - - monkeypatch.setattr(clients_mod, "KnowledgeClient", FailingKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn) - - result = await read_note("missing-note", project=test_project.name, page=2, page_size=3) - - # Title lookup is pinned to page 1 with a fixed lookup size (it exists to - # find THE note by exact title); caller page/page_size apply only to the - # text-search suggestions. - assert captured_pages == [("title", 1, 10), ("text", 2, 3)] - assert "Note Not Found" in result - - -@pytest.mark.asyncio -async def test_read_note_title_fallback_finds_exact_match_on_later_page( - monkeypatch, app, test_project -): - """An exact title match is returned even when the caller asks for page > 1. - - The title-match lookup is pinned to page 1 of title results; without the pin, - read_note("Exact Title", page=2) would page past the match and return - unrelated suggestions instead of the note. - """ - await bm_write( - project=test_project.name, - title="Paged Title Note", - directory="test", - content="paged title content", - ) - - import importlib - from basic_memory.schemas.memory import memory_url_path - - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - direct_identifier = memory_url_path("Paged Title Note") - - class SelectiveKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - # Fail on the direct identifier to force fallback to title search - if identifier == direct_identifier: - raise lookup_miss() - return await super().resolve_entity(identifier, strict=strict) - - monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient) - - content = await read_note("Paged Title Note", project=test_project.name, page=2) - assert "paged title content" in content - - -@pytest.mark.asyncio -async def test_read_note_title_fallback_finds_exact_match_with_small_page_size( - monkeypatch, app, test_project -): - """An exact title match is returned even when the caller asks for a tiny page_size. - - The title-match lookup uses a fixed lookup size; without it, a higher-ranked - fuzzy title ("Foo Bar Foo Bar") would displace the exact title ("Foo Bar") - out of a page_size=1 window and read_note("Foo Bar", page_size=1) would - return suggestions instead of the note. - """ - await bm_write( - project=test_project.name, - title="Foo Bar Foo Bar", - directory="test", - content="fuzzy decoy content", - ) - await bm_write( - project=test_project.name, - title="Foo Bar", - directory="test", - content="exact title content", - ) - - import importlib - from basic_memory.schemas.memory import memory_url_path - - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - direct_identifier = memory_url_path("Foo Bar") - - class SelectiveKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - # Fail on the direct identifier to force fallback to title search - if identifier == direct_identifier: - raise lookup_miss() - return await super().resolve_entity(identifier, strict=strict) - - monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient) - - content = await read_note("Foo Bar", project=test_project.name, page_size=1) - assert "exact title content" in content - - -@pytest.mark.asyncio -async def test_read_note_title_fallback_finds_exact_title_past_fuzzy_titles( - monkeypatch, app, test_project -): - """An exact title match is found even when more fuzzy titles contain the phrase. - - bm25 ranks titles that repeat the queried phrase above the exact title. Title - search now sorts an exact title first (#1956), so with more than - _TITLE_LOOKUP_PAGE_SIZE such decoys the exact match still leads page 1. - """ - from basic_memory.mcp.tools.read_note import _TITLE_LOOKUP_PAGE_SIZE - from basic_memory.mcp.tools.search import search_notes - - for index in range(1, _TITLE_LOOKUP_PAGE_SIZE + 2): - await bm_write( - project=test_project.name, - title=f"Deep Page Note Deep Page Note Deep Page Note {index:02d}", - directory="test", - content=f"fuzzy decoy content {index}", - ) - await bm_write( - project=test_project.name, - title="Deep Page Note", - directory="test", - content="deep page exact content", - ) - - first_page = await search_notes( - project=test_project.name, - query="Deep Page Note", - search_type="title", - page=1, - page_size=_TITLE_LOOKUP_PAGE_SIZE, - output_format="json", - ) - assert isinstance(first_page, dict) - first_page_titles = [result["title"] for result in first_page["results"]] - assert first_page_titles[0] == "Deep Page Note" - assert first_page["has_more"] is True - - import importlib - from basic_memory.schemas.memory import memory_url_path - - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - direct_identifier = memory_url_path("Deep Page Note") - - class SelectiveKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - # Fail on the direct identifier to force fallback to title search - if identifier == direct_identifier: - raise lookup_miss() - return await super().resolve_entity(identifier, strict=strict) - - monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient) - - content = await read_note("Deep Page Note", project=test_project.name) - assert "deep page exact content" in content - - -@pytest.mark.asyncio -async def test_read_note_title_lookup_stops_at_page_cap(monkeypatch, app, test_project): - """The title lookup is bounded: after the page cap it falls through to suggestions.""" - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - - captured_pages: list[tuple[str, int]] = [] - - async def fake_search_notes_fn(*, query, search_type, page, page_size, **kwargs): - captured_pages.append((search_type, page)) - if search_type == "title": - # Endless fuzzy titles: every page is full and reports more available, - # simulating a pathological knowledge base that never yields the note. - return { - "results": [ - { - "title": f"Fuzzy {page}-{index}", - "permalink": f"docs/fuzzy-{page}-{index}", - "content": "", - "type": "entity", - "score": 1.0, - "file_path": f"docs/fuzzy-{page}-{index}.md", - } - for index in range(page_size) - ], - "current_page": page, - "page_size": page_size, - "has_more": True, - } - return {"results": [], "current_page": page, "page_size": page_size} - - class FailingKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - raise lookup_miss() - - monkeypatch.setattr(clients_mod, "KnowledgeClient", FailingKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn) - - result = await read_note("Pathological Note", project=test_project.name) - - title_pages = [page for search_type, page in captured_pages if search_type == "title"] - assert title_pages == list(range(1, read_note_module._TITLE_LOOKUP_MAX_PAGES + 1)) - assert "Note Not Found" in result - - -@pytest.mark.asyncio -async def test_read_note_related_results_list_full_search_page(monkeypatch, app, test_project): - """Suggestions list the whole returned search page instead of a hardcoded cap of 5.""" - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - - candidates = [ - { - "title": f"Related {index}", - "permalink": f"docs/related-{index}", - "content": "", - "type": "entity", - "score": 1.0, - "file_path": f"docs/related-{index}.md", - } - for index in range(1, 7) - ] - - async def fake_search_notes_fn(*, query, search_type, **kwargs): - if search_type == "title": - return {"results": [], "current_page": 1, "page_size": 10} - return {"results": candidates, "current_page": 1, "page_size": 10} - - class FailingKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - raise lookup_miss() - - monkeypatch.setattr(clients_mod, "KnowledgeClient", FailingKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn) - - text_result = await read_note("missing-note", project=test_project.name) - assert "## 6. Related 6" in text_result - - json_result = await read_note( - "missing-note", - project=test_project.name, - output_format="json", - ) - assert isinstance(json_result, dict) - assert len(json_result["related_results"]) == 6 - - -@pytest.mark.asyncio -async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch, app, test_project): - """Do not fetch note content when title-search returns only fuzzy matches.""" - await bm_write( - project=test_project.name, - title="Existing Note", - directory="test", - content="existing note content", - ) - - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - - class StrictFailingKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - if strict: - raise lookup_miss() - return await super().resolve_entity(identifier, strict=strict) - - async def fake_search_notes_fn(*, query, search_type, **kwargs): - if search_type == "title": - return { - "results": [ - { - "title": "Existing Note", - "permalink": "test/existing-note", - "content": "", - "type": "entity", - "score": 1.0, - "file_path": "test/Existing Note.md", - } - ], - "current_page": 1, - "page_size": 10, - } - return {"results": [], "current_page": 1, "page_size": 10} - - monkeypatch.setattr(clients_mod, "KnowledgeClient", StrictFailingKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn) - - result = await read_note("Missing Exact Title", project=test_project.name) - assert "Note Not Found" in result - assert "Missing Exact Title" in result - assert "existing note content" not in result - - -@pytest.mark.asyncio -async def test_read_note_explicit_workspace_project_ignores_stale_cached_project( - monkeypatch, - config_manager, -): - """Explicit workspace routing should use the resolved cloud UUID, not stale cache.""" - import importlib - - import basic_memory.mcp.project_context as project_context - from basic_memory.mcp.project_context import WorkspaceProjectEntry - from basic_memory.schemas.project_info import ProjectItem - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - - config = config_manager.load_config() - config.cloud_api_key = "bmc_test123" - config_manager.save_config(config) - - personal = project_context.WorkspaceInfo( - tenant_id="personal-tenant", - workspace_type="personal", - slug="personal", - name="Personal", - role="owner", - is_default=True, - ) - expected_uuid = "22222222-2222-2222-2222-222222222222" - expected_project = ProjectItem( - id=2, - external_id=expected_uuid, - name="main", - path="/tmp/main", - is_default=False, - ) - stale_project = ProjectItem( - id=99, - external_id="33333333-3333-3333-3333-333333333333", - name="main", - path="/tmp/stale-main", - is_default=False, - ) - context = ContextState() - await context.set_state("active_workspace", personal.model_dump()) - await context.set_state("active_project", stale_project.model_dump()) - - async def fake_resolve_workspace_project_identifier(project_name, context=None): - assert project_name == "personal/main" - return WorkspaceProjectEntry(workspace=personal, project=expected_project) - - @asynccontextmanager - async def fake_get_client(project_name=None, workspace=None): - assert project_name == "main" - assert workspace == "personal-tenant" - yield object() - - class FakeProjectResolveResponse: - def json(self): - return { - "external_id": expected_uuid, - "project_id": expected_project.id, - "name": expected_project.name, - "permalink": expected_project.permalink, - "path": expected_project.path, - "is_active": True, - "is_default": False, - "resolution_method": "permalink", - } - - async def fake_call_post(*args, **kwargs): - return FakeProjectResolveResponse() - - class FakeKnowledgeClient: - def __init__(self, client, project_id): - assert project_id == expected_uuid - - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - assert identifier == "personal/main/todo" - return "entity-1" - - async def get_entity(self, entity_id: str): - assert entity_id == "entity-1" - return SimpleNamespace( - title="TODO", - permalink="personal/main/todo", - file_path="TODO.md", - content="---\ntitle: TODO\n---\n\n# TODO - Priorities & Tasks\n", - entity_metadata={"title": "TODO"}, - db_checksum="abc123", - ) - - class FakeResourceClient: - def __init__(self, client, project_id): - assert project_id == expected_uuid - - async def read(self, entity_id: str): - raise AssertionError(f"accepted content must avoid resource read for {entity_id}") - - monkeypatch.setattr( - project_context, - "resolve_workspace_project_identifier", - fake_resolve_workspace_project_identifier, - ) - monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client) - monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False) - monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False) - monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False) - monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post) - monkeypatch.setattr(clients_mod, "KnowledgeClient", FakeKnowledgeClient) - monkeypatch.setattr(clients_mod, "ResourceClient", FakeResourceClient) - - result = await read_note_module.read_note( - "memory://todo", - project="personal/main", - output_format="json", - context=ctx(context), - ) - - assert isinstance(result, dict) - assert result["permalink"] == "personal/main/todo" - assert result["content"].strip() == "# TODO - Priorities & Tasks" - - -@pytest.mark.asyncio -async def test_note_unicode_content(app, test_project): - """Test handling of unicode content in""" - content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦" - created = await bm_write( - project=test_project.name, - title="Unicode Test", - directory="test", - content=content, - output_format="json", - ) - - assert isinstance(created, dict) - assert created["action"] == "created" - assert created["file_path"] == "test/Unicode Test.md" - assert created["permalink"] == f"{test_project.name}/test/unicode-test" - - # Read back should preserve unicode - result = await read_note("test/unicode-test", project=test_project.name) - assert content in result - - -@pytest.mark.asyncio -async def test_multiple_notes(app, test_project): - """Test creating and managing multiple notes""" - # Create several notes - notes_data = [ - ("test/note-1", "Note 1", "test", "Content 1", ["tag1"]), - ("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]), - ("test/note-3", "Note 3", "test", "Content 3", []), - ] - - for _, title, folder, content, tags in notes_data: - await bm_write( - project=test_project.name, title=title, directory=folder, content=content, tags=tags - ) - - # Should be able to read each one individually - for permalink, title, folder, content, _ in notes_data: - note = await read_note(permalink, project=test_project.name) - assert content in note - - # Note: v2 API does not support glob patterns in read_note - # Glob patterns should be used with build_context or list_directory instead - # For reading multiple notes, use build_context with memory:// URLs - - -@pytest.mark.asyncio -async def test_read_multiple_notes_individually(app, test_project): - """Test reading several notes back individually after writing them.""" - notes_data = [ - ("test/note-1", "Note 1", "test", "Content 1", ["tag1"]), - ("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]), - ("test/note-3", "Note 3", "test", "Content 3", []), - ] - - for _, title, folder, content, tags in notes_data: - await bm_write( - project=test_project.name, title=title, directory=folder, content=content, tags=tags - ) - - for permalink, title, folder, content, _ in notes_data: - note = await read_note(permalink, project=test_project.name) - assert content in note - - # Note: v2 API does not support glob patterns in read_note - # For reading multiple notes, use build_context or list_directory instead - - -@pytest.mark.asyncio -async def test_read_note_memory_url(app, test_project): - """Test reading a note using a memory:// URL. - - Should: - - Handle memory:// URLs correctly - - Normalize the URL before resolving - - Return the note content - """ - # First create a note - result = await bm_write( - project=test_project.name, - title="Memory URL Test", - directory="test", - content="Testing memory:// URL handling", - ) - assert result - - # Should be able to read it with a memory:// URL - memory_url = "memory://test/memory-url-test" - content = await read_note(memory_url, project=test_project.name) - assert "Testing memory:// URL handling" in content - - -@pytest.mark.asyncio -async def test_read_note_memory_url_with_project_prefix(app, test_project): - """Test reading a note using a memory:// URL with explicit project prefix.""" - await bm_write( - project=test_project.name, - title="Project Prefixed Memory URL Test", - directory="test", - content="Testing memory:// URL handling with project prefix", - ) - - memory_url = f"memory://{test_project.name}/test/project-prefixed-memory-url-test" - content = await read_note(memory_url) - assert "Testing memory:// URL handling with project prefix" in content - - -@pytest.mark.asyncio -async def test_read_note_skips_url_detection_when_project_id_provided( - monkeypatch, - app, - test_project, -): - """project_id is authoritative, so memory URL discovery must not run first.""" - await bm_write( - project=test_project.name, - title="Project ID Memory URL Read", - directory="test", - content="Read by project id without discovery", - ) - - async def fail_if_called(*args, **kwargs): - raise AssertionError("project_id routing should bypass URL discovery") - - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - monkeypatch.setattr(read_note_module, "detect_project_from_identifier_prefix", fail_if_called) - - result = await read_note( - f"memory://{test_project.name}/test/project-id-memory-url-read", - project_id=test_project.external_id, - output_format="json", - ) - - assert isinstance(result, dict) - assert result["content"].strip() == "Read by project id without discovery" - - -@pytest.mark.asyncio -async def test_team_workspace_write_stores_complete_canonical_permalink( - app, - test_project, - entity_repository, - session_maker, -): - from basic_memory.workspace_context import workspace_permalink_context - - expected_permalink = f"team-paul/{test_project.name}/team/team-workspace-note" - - with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): - write_result = await bm_write( - project=test_project.name, - title="Team Workspace Note", - directory="team", - content="Team canonical content", - ) - - assert f"permalink: {expected_permalink}" in write_result - - async with db.scoped_session(session_maker) as session: - stored = await entity_repository.get_by_permalink(session, expected_permalink) - assert stored is not None - assert stored.permalink == expected_permalink - - read_result = await read_note( - expected_permalink, - project=test_project.name, - output_format="json", - ) - - assert isinstance(read_result, dict) - assert read_result["permalink"] == expected_permalink - assert read_result["content"].strip() == "Team canonical content" - - -@pytest.mark.asyncio -async def test_team_workspace_write_stores_complete_permalink_when_project_prefixes_disabled( - app, - test_project, - entity_repository, - config_manager, - session_maker, -): - from basic_memory.workspace_context import workspace_permalink_context - - config = config_manager.load_config() - config.permalinks_include_project = False - config_manager.save_config(config) - - expected_permalink = f"team-paul/{test_project.name}/team/team-no-project-prefix-note" - - with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): - write_result = await bm_write( - project=test_project.name, - title="Team No Project Prefix Note", - directory="team", - content="Team canonical content without project-prefix config", - ) - - assert f"permalink: {expected_permalink}" in write_result - - async with db.scoped_session(session_maker) as session: - stored = await entity_repository.get_by_permalink(session, expected_permalink) - assert stored is not None - assert stored.permalink == expected_permalink - - read_result = await read_note( - f"memory://{expected_permalink}", - project=test_project.name, - output_format="json", - ) - - assert isinstance(read_result, dict) - assert read_result["permalink"] == expected_permalink - assert read_result["content"].strip() == "Team canonical content without project-prefix config" - - -@pytest.mark.asyncio -async def test_personal_workspace_write_stores_complete_canonical_permalink( - app, - test_project, - entity_repository, - session_maker, -): - from basic_memory.workspace_context import workspace_permalink_context - - expected_permalink = f"personal/{test_project.name}/personal/personal-workspace-note" - - with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"): - write_result = await bm_write( - project=test_project.name, - title="Personal Workspace Note", - directory="personal", - content="Personal canonical content", - ) - - assert f"permalink: {expected_permalink}" in write_result - - async with db.scoped_session(session_maker) as session: - stored = await entity_repository.get_by_permalink(session, expected_permalink) - assert stored is not None - assert stored.permalink == expected_permalink - - -@pytest.mark.asyncio -async def test_read_note_personal_workspace_keeps_short_project_permalink_working( - app, - test_project, -): - from basic_memory.workspace_context import workspace_permalink_context - - legacy_permalink = f"{test_project.name}/personal/short-personal-note" - - await bm_write( - project=test_project.name, - title="Short Personal Note", - directory="personal", - content="Short personal workspace content", - ) - - with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"): - read_result = await read_note( - f"memory://{legacy_permalink}", - project=test_project.name, - output_format="json", - ) - - assert isinstance(read_result, dict) - assert read_result["permalink"] == legacy_permalink - assert read_result["content"].strip() == "Short personal workspace content" - - -@pytest.mark.asyncio -async def test_read_note_workspace_qualified_personal_url_finds_legacy_short_permalink( - app, - test_project, - entity_repository, - session_maker, -): - from basic_memory.workspace_context import workspace_permalink_context - - legacy_permalink = f"{test_project.name}/personal/legacy-personal-note" - qualified_permalink = f"personal/{legacy_permalink}" - - await bm_write( - project=test_project.name, - title="Legacy Personal Note", - directory="personal", - content="Legacy personal workspace content", - ) - - async with db.scoped_session(session_maker) as session: - stored = await entity_repository.get_by_permalink(session, legacy_permalink) - assert stored is not None - assert stored.permalink == legacy_permalink - - with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"): - read_result = await read_note( - f"memory://{qualified_permalink}", - project=test_project.name, - output_format="json", - ) - - assert isinstance(read_result, dict) - assert read_result["permalink"] == legacy_permalink - assert read_result["content"].strip() == "Legacy personal workspace content" - - -@pytest.mark.asyncio -async def test_read_note_workspace_qualified_memory_url_uses_complete_permalink( - app, - test_project, -): - from basic_memory.workspace_context import workspace_permalink_context - - expected_permalink = f"team-paul/{test_project.name}/team/tool-read-note" - - with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): - await bm_write( - project=test_project.name, - title="Tool Read Note", - directory="team", - content="Workspace URL read content", - ) - - result = await read_note( - f"memory://{expected_permalink}", - project=test_project.name, - output_format="json", - ) - - assert isinstance(result, dict) - assert result["permalink"] == expected_permalink - assert result["content"].strip() == "Workspace URL read content" - - -@pytest.mark.asyncio -async def test_read_note_memory_url_fallback_uses_search_tool_normalization( - monkeypatch, app, test_project -): - """Fallback search should go back through search_notes for memory:// normalization.""" - await bm_write( - project=test_project.name, - title="Memory URL Fallback Note", - directory="test", - content="Fallback note content", - ) - - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - OriginalKnowledgeClient = clients_mod.KnowledgeClient - - fallback_memory_url = f"memory://{test_project.name}/test/memory-url-fallback-note" - search_calls: list[tuple[str, str, str | None]] = [] - - class SelectiveKnowledgeClient(OriginalKnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - if strict and identifier.endswith("test/memory-url-fallback-note"): - raise lookup_miss() - return await super().resolve_entity(identifier, strict=strict) - - async def fake_search_notes_fn(*, query, search_type, project, **kwargs): - search_calls.append((search_type, query, project)) - return { - "results": [ - { - "title": "Memory URL Fallback Note", - "permalink": "test/memory-url-fallback-note", - "content": "", - "type": "entity", - "score": 1.0, - "file_path": "test/Memory URL Fallback Note.md", - } - ], - "current_page": 1, - "page_size": 10, - } - - monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn) - - result = await read_note(fallback_memory_url) - - assert search_calls == [ - ("title", fallback_memory_url, test_project.name), - ("text", fallback_memory_url, test_project.name), - ] - assert "No exact match for" in result - assert "Memory URL Fallback Note" in result - - -class TestReadNoteSecurityValidation: - """Test read_note security validation features.""" - - @pytest.mark.asyncio - async def test_read_note_blocks_path_traversal_unix(self, app, test_project): - """Test that Unix-style path traversal attacks are blocked in identifier parameter.""" - # Test various Unix-style path traversal patterns - attack_identifiers = [ - "../secrets.txt", - "../../etc/passwd", - "../../../root/.ssh/id_rsa", - "notes/../../../etc/shadow", - "folder/../../outside/file.md", - "../../../../etc/hosts", - "../../../home/user/.env", - ] - - for attack_identifier in attack_identifiers: - result = await read_note(attack_identifier, project=test_project.name) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - assert attack_identifier in result - - @pytest.mark.asyncio - async def test_read_note_blocks_path_traversal_windows(self, app, test_project): - """Test that Windows-style path traversal attacks are blocked in identifier parameter.""" - # Test various Windows-style path traversal patterns - attack_identifiers = [ - "..\\secrets.txt", - "..\\..\\Windows\\System32\\config\\SAM", - "notes\\..\\..\\..\\Windows\\System32", - "\\\\server\\share\\file.txt", - "..\\..\\Users\\user\\.env", - "\\\\..\\..\\Windows", - "..\\..\\..\\Boot.ini", - ] - - for attack_identifier in attack_identifiers: - result = await read_note(attack_identifier, project=test_project.name) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - assert attack_identifier in result - - @pytest.mark.asyncio - async def test_read_note_blocks_absolute_paths(self, app, test_project): - """Test that absolute paths are blocked in identifier parameter.""" - # Test various absolute path patterns - attack_identifiers = [ - "/etc/passwd", - "/home/user/.env", - "/var/log/auth.log", - "/root/.ssh/id_rsa", - "C:\\Windows\\System32\\config\\SAM", - "C:\\Users\\user\\.env", - "D:\\secrets\\config.json", - "/tmp/malicious.txt", - "/usr/local/bin/evil", - ] - - for attack_identifier in attack_identifiers: - result = await read_note(project=test_project.name, identifier=attack_identifier) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - assert attack_identifier in result - - @pytest.mark.asyncio - async def test_read_note_blocks_home_directory_access(self, app, test_project): - """Test that home directory access patterns are blocked in identifier parameter.""" - # Test various home directory access patterns - attack_identifiers = [ - "~/secrets.txt", - "~/.env", - "~/.ssh/id_rsa", - "~/Documents/passwords.txt", - "~\\AppData\\secrets", - "~\\Desktop\\config.ini", - "~/.bashrc", - "~/Library/Preferences/secret.plist", - ] - - for attack_identifier in attack_identifiers: - result = await read_note(project=test_project.name, identifier=attack_identifier) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - assert attack_identifier in result - - @pytest.mark.asyncio - async def test_read_note_blocks_memory_url_attacks(self, app, test_project): - """Test that memory URLs with path traversal are blocked.""" - # Test memory URLs with attacks embedded - attack_identifiers = [ - "memory://../../etc/passwd", - "memory://../../../root/.ssh/id_rsa", - "memory://~/.env", - "memory:///etc/passwd", - "memory://notes/../../../etc/shadow", - "memory://..\\..\\Windows\\System32", - ] - - for attack_identifier in attack_identifiers: - result = await read_note(project=test_project.name, identifier=attack_identifier) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - @pytest.mark.asyncio - async def test_read_note_blocks_mixed_attack_patterns(self, app, test_project): - """Test that mixed legitimate/attack patterns are blocked in identifier parameter.""" - # Test mixed patterns that start legitimate but contain attacks - attack_identifiers = [ - "notes/../../../etc/passwd", - "docs/../../.env", - "legitimate/path/../../.ssh/id_rsa", - "project/folder/../../../Windows/System32", - "valid/folder/../../home/user/.bashrc", - "assets/../../../tmp/evil.exe", - ] - - for attack_identifier in attack_identifiers: - result = await read_note(project=test_project.name, identifier=attack_identifier) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - @pytest.mark.asyncio - async def test_read_note_allows_safe_identifiers(self, app, test_project): - """Test that legitimate identifiers are still allowed.""" - # Test various safe identifier patterns - safe_identifiers = [ - "notes/meeting", - "docs/readme", - "projects/2025/planning", - "archive/old-notes/backup", - "folder/subfolder/document", - "research/ml/algorithms", - "meeting-notes", - "test/simple-note", - ] - - for safe_identifier in safe_identifiers: - result = await read_note(project=test_project.name, identifier=safe_identifier) - - assert isinstance(result, str) - # Should not contain security error message - assert ( - "# Error" not in result or "paths must stay within project boundaries" not in result - ) - # Should either succeed or fail for legitimate reasons (not found, etc.) - # but not due to security validation - - @pytest.mark.asyncio - async def test_read_note_allows_legitimate_titles(self, app, test_project): - """Test that legitimate note titles work normally.""" - # Create a test note first - await bm_write( - project=test_project.name, - title="Security Test Note", - directory="security-tests", - content="# Security Test Note\nThis is a legitimate note for security testing.", - ) - - # Test reading by title (should work) - result = await read_note("Security Test Note", project=test_project.name) - - assert isinstance(result, str) - # Should not be a security error - assert "# Error" not in result or "paths must stay within project boundaries" not in result - # Should either return the note content or search results - - @pytest.mark.asyncio - async def test_read_note_empty_identifier_security(self, app, test_project): - """Test that empty identifier is handled securely.""" - # Empty identifier should be allowed (may return search results or error, but not security error) - result = await read_note(identifier="", project=test_project.name) - - assert isinstance(result, str) - # Empty identifier should not trigger security error - assert "# Error" not in result or "paths must stay within project boundaries" not in result - - @pytest.mark.asyncio - async def test_read_note_security_with_all_parameters(self, app, test_project): - """Test security validation works with all read_note parameters.""" - # Test that security validation is applied even when all other parameters are provided - result = await read_note( - project=test_project.name, - identifier="../../../etc/malicious", - ) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - assert "../../../etc/malicious" in result - - @pytest.mark.asyncio - async def test_read_note_security_logging(self, app, caplog, test_project): - """Test that security violations are properly logged.""" - # Attempt path traversal attack - result = await read_note(identifier="../../../etc/passwd", project=test_project.name) - - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - # Check that security violation was logged - # Note: This test may need adjustment based on the actual logging setup - # The security validation should generate a warning log entry - - @pytest.mark.asyncio - async def test_read_note_preserves_functionality_with_security(self, app, test_project): - """Test that security validation doesn't break normal note reading functionality.""" - # Create a note with complex content to ensure security validation doesn't interfere - await bm_write( - project=test_project.name, - title="Full Feature Security Test Note", - directory="security-tests", - content=dedent(""" - # Full Feature Security Test Note - - This note tests that security validation doesn't break normal functionality. - - ## Observations - - [security] Path validation working correctly #security - - [feature] All features still functional #test - - ## Relations - - relates_to [[Security Implementation]] - - depends_on [[Path Validation]] - - Additional content with various formatting. - """).strip(), - tags=["security", "test", "full-feature"], - note_type="guide", - ) - - # Test reading by permalink - result = await read_note( - "security-tests/full-feature-security-test-note", project=test_project.name - ) - - # Should succeed normally (not a security error) - assert isinstance(result, str) - assert "# Error" not in result or "paths must stay within project boundaries" not in result - # Should either return content or search results, but not security error - - -class TestReadNoteSecurityEdgeCases: - """Test edge cases for read_note security validation.""" - - @pytest.mark.asyncio - async def test_read_note_unicode_identifier_attacks(self, app, test_project): - """Test that Unicode-based path traversal attempts are blocked.""" - # Test Unicode path traversal attempts - unicode_attack_identifiers = [ - "notes/文档/../../../etc/passwd", # Chinese characters - "docs/café/../../.env", # Accented characters - "files/αβγ/../../../secret.txt", # Greek characters - ] - - for attack_identifier in unicode_attack_identifiers: - result = await read_note(attack_identifier, project=test_project.name) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - @pytest.mark.asyncio - async def test_read_note_very_long_attack_identifier(self, app, test_project): - """Test handling of very long attack identifiers.""" - # Create a very long path traversal attack - long_attack_identifier = "../" * 1000 + "etc/malicious" - - result = await read_note(long_attack_identifier, project=test_project.name) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - @pytest.mark.asyncio - async def test_read_note_case_variations_attacks(self, app, test_project): - """Test that case variations don't bypass security.""" - # Test case variations (though case sensitivity depends on filesystem) - case_attack_identifiers = [ - "../ETC/passwd", - "../Etc/PASSWD", - "..\\WINDOWS\\system32", - "~/.SSH/id_rsa", - ] - - for attack_identifier in case_attack_identifiers: - result = await read_note(attack_identifier, project=test_project.name) - - assert isinstance(result, str) - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - @pytest.mark.asyncio - async def test_read_note_whitespace_in_attack_identifiers(self, app, test_project): - """Test that whitespace doesn't help bypass security.""" - # Test attack identifiers with various whitespace - whitespace_attack_identifiers = [ - " ../../../etc/passwd ", - "\t../../../secrets\t", - " ..\\..\\Windows ", - "notes/ ../../ malicious", - ] - - for attack_identifier in whitespace_attack_identifiers: - result = await read_note(attack_identifier, project=test_project.name) - - assert isinstance(result, str) - # The attack should still be blocked even with whitespace - if ".." in attack_identifier.strip() or "~" in attack_identifier.strip(): - assert "# Error" in result - assert "paths must stay within project boundaries" in result - - -@pytest.mark.asyncio -async def test_missing_uuid_line_scan_returns_not_found_guidance(app, test_project) -> None: - result = await read_note( - "22222222-2222-4222-8222-222222222222", - project=test_project.name, - start_line=1, - end_line=10, - ) - assert isinstance(result, str) - assert "Note Not Found" in result - - -# --- Explicit Markdown path misses (#1609) --- -# The default test project has never completed a full index pass (last_indexed_at -# is NULL), the same readiness a hosted project reports while its notes still read. - - -@pytest.mark.asyncio -async def test_missing_markdown_path_in_unindexed_project_is_not_found( - implementation_server, app, test_project -): - await bm_write( - project=test_project.name, title="Readable", directory="notes", content="Visible body" - ) - async with Client(implementation_server) as mcp_client: - existing = await mcp_client.call_tool( - "read_note", - { - "identifier": "notes/Readable.md", - "project": test_project.name, - "output_format": "json", - }, - ) - missing = await mcp_client.call_tool( - "read_note", - { - "identifier": "notes/runs/2026-09-25.md", - "project": test_project.name, - "output_format": "json", - }, - ) - assert "Visible body" in json.loads(existing.content[0].text)["content"] - assert json.loads(missing.content[0].text) == { - "title": None, - "permalink": None, - "file_path": None, - "content": None, - "frontmatter": None, - "error": "NOTE_NOT_FOUND", - "message": "Note not found: notes/runs/2026-09-25.md", - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("identifier", ["notes/missing.md", "memory://notes/missing.markdown"]) -async def test_missing_markdown_path_skips_search_fallback( - monkeypatch, app, test_project, identifier -): - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - - async def search_must_not_run(**kwargs): - raise AssertionError("an explicit path miss must not fall back to search") - - monkeypatch.setattr(read_note_module, "search_notes", search_must_not_run) - - text = await read_note(identifier, project=test_project.name) - assert isinstance(text, str) - assert "# Note Not Found" in text - assert "Project Index Required" not in text - - scan = await read_note(identifier, project=test_project.name, start_line=1, end_line=5) - assert isinstance(scan, str) - assert "# Note Not Found" in scan - - payload = await read_note(identifier, project=test_project.name, output_format="json") - assert isinstance(payload, dict) - assert payload["error"] == "NOTE_NOT_FOUND" - - -@pytest.mark.asyncio -async def test_missing_title_in_unindexed_project_keeps_index_guidance(app, test_project): - """A title miss cannot prove absence before an index pass, so guidance still surfaces.""" - with pytest.raises(RuntimeError, match="Project Index Required"): - await read_note("Some Missing Title", project=test_project.name, output_format="json") - - -@pytest.mark.asyncio -async def test_unavailable_resolver_does_not_prove_markdown_path_missing( - monkeypatch, app, test_project -): - """Only a resolver 404 confirms absence; other failures keep the prior recovery.""" - import importlib - - read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - searched: list[str] = [] - - def resolver_unavailable() -> ToolError: - request = Request("POST", "http://test/knowledge/resolve") - error = ToolError("resolver unavailable") - error.__cause__ = HTTPStatusError( - "resolver unavailable", request=request, response=Response(503, request=request) - ) - return error - - class UnavailableKnowledgeClient(clients_mod.KnowledgeClient): - @override - async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: - raise resolver_unavailable() - - async def fake_search_notes(*, search_type, **kwargs): - searched.append(search_type) - return {"results": [], "current_page": 1, "page_size": 10} - - monkeypatch.setattr(clients_mod, "KnowledgeClient", UnavailableKnowledgeClient) - monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes) - - await read_note("notes/present.md", project=test_project.name) - assert searched == ["title", "text"] - - with pytest.raises(ToolError, match="resolver unavailable"): - await read_note("notes/present.md", project=test_project.name, output_format="json") - - -@pytest.mark.asyncio -async def test_read_note_json_checksum_guards_an_overwrite(app, test_project): - """The checksum read_note returns is the revision write_note's expected_checksum checks.""" - written = await bm_write( - project=test_project.name, - title="Guarded Note", - directory="test", - content="first revision", - output_format="json", - ) - assert isinstance(written, dict) - - read = await read_note("test/guarded-note", project=test_project.name, output_format="json") - assert isinstance(read, dict) - assert read["checksum"] == written["checksum"] - - replaced = await bm_write( - project=test_project.name, - title="Guarded Note", - directory="test", - content="second revision", - overwrite=True, - expected_checksum=read["checksum"], - output_format="json", - ) - assert isinstance(replaced, dict) - assert replaced["action"] == "updated" - - with pytest.raises(ToolError, match="REVISION_CONFLICT"): - await bm_write( - project=test_project.name, - title="Guarded Note", - directory="test", - content="stale third revision", - overwrite=True, - expected_checksum=read["checksum"], - ) - - -# --- Line ranges past the end of the document (#1634) --- - - -@pytest.mark.asyncio -@pytest.mark.parametrize("output_format", ["text", "json"]) -async def test_line_scan_past_end_reports_document_length( - app, test_project, entity_repository, session_maker, output_format -): - """A start_line past the last line raises a clear error instead of 'Lines 500-N'.""" - await bm_write( - project=test_project.name, - title="Short Lines", - directory="notes", - content="one\ntwo", - ) - full = await read_note( - "notes/Short Lines.md", - project=test_project.name, - include_frontmatter=True, - output_format="json", - ) - assert isinstance(full, dict) - total = len(full["content"].splitlines()) - async with db.scoped_session(session_maker) as session: - stored = await entity_repository.get_by_file_path(session, "notes/Short Lines.md") - assert stored is not None - - # The path resolves first; the exact UUID takes the direct-read branch. - for identifier in ("notes/Short Lines.md", stored.external_id): - with pytest.raises( - ToolError, - match=f"start_line 500 is past the end of the document \\({total} lines\\)", - ): - await read_note( - identifier, - project=test_project.name, - start_line=500, - end_line=510, - output_format=output_format, - ) - - -def test_format_note_metadata_prints_the_full_checksum_and_marks_missing_values(): - """The text metadata read never truncates the checksum, and says when it has none.""" - checksum = "c" * 64 - text = format_note_metadata( - {"title": "T", "permalink": None, "file_path": "t.md", "checksum": checksum} - ) - assert text.splitlines() == [ - "# Note metadata", - "title: T", - "permalink: unknown", - "file_path: t.md", - f"checksum: {checksum}", - ] diff --git a/tests/mcp/test_tool_telemetry.py b/tests/mcp/test_tool_telemetry.py index 3d95de383..e580979ae 100644 --- a/tests/mcp/test_tool_telemetry.py +++ b/tests/mcp/test_tool_telemetry.py @@ -1,53 +1,42 @@ -"""Telemetry coverage for MCP tool root spans.""" +"""Telemetry for the published bm_* tools: API request spans and the routed project. -from __future__ import annotations +The spans come from the real stack, captured with logfire's test exporter. +""" -import importlib -from contextlib import contextmanager - -import logfire -import pytest +from collections import Counter from typing import Any -from basic_memory.mcp.tools import bm_context, bm_write - -edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") -read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") -search_module = importlib.import_module("basic_memory.mcp.tools.search") - - -class _NoopSpan: - """Minimal stand-in for a live logfire span during tests.""" - - def set_attributes(self, attrs: dict[str, Any]) -> None: - pass +import pytest +from logfire.testing import CaptureLogfire, capfire as capfire - def set_attribute(self, key: str, value) -> None: - pass +from basic_memory.mcp.tools import bm_context, bm_edit, bm_read, bm_search, bm_write -def _recording_spans(): - spans: list[tuple[str, dict[str, Any]]] = [] +def _spans(capfire: CaptureLogfire) -> list[tuple[str, dict[str, Any]]]: + return [ + (span["name"], span["attributes"]) + for span in capfire.exporter.exported_spans_as_dict() + if span["attributes"].get("logfire.span_type") != "pending_span" + ] - @contextmanager - def fake_span(name: str, **attrs): - spans.append((name, attrs)) - yield _NoopSpan() - return spans, fake_span +def _api_requests(spans: list[tuple[str, dict[str, Any]]]) -> Counter[str]: + """How many times each typed client called the API.""" + return Counter(name for name, _ in spans if name.startswith("api.request.")) -def _contains_span_attrs( - spans: list[tuple[str, dict[str, Any]]], name: str, expected: dict[str, Any] -) -> bool: +def _routed_to(spans: list[tuple[str, dict[str, Any]]], project_name: str) -> bool: return any( - span_name == name and expected.items() <= attrs.items() for span_name, attrs in spans + name == "routing.client_session" + and attrs.get("project_name") == project_name + and attrs.get("route_mode") == "local_asgi" + for name, attrs in spans ) @pytest.mark.asyncio -async def test_read_note_emits_root_operation_and_project_context( - app, test_project, monkeypatch +async def test_bm_read_resolves_once_and_reads_the_entity_once( + app, test_project, capfire: CaptureLogfire ) -> None: await bm_write( project=test_project.name, @@ -55,157 +44,66 @@ async def test_read_note_emits_root_operation_and_project_context( directory="notes", content="Readable telemetry content", ) + capfire.exporter.clear() - spans, fake_span = _recording_spans() - monkeypatch.setattr(logfire, "span", fake_span) + await bm_read("notes/readable-telemetry-note", project=test_project.name, output_format="json") - await read_note_module.read_note( - "notes/readable-telemetry-note", - project=test_project.name, - output_format="json", - include_frontmatter=True, - ) - - assert spans[0] == ( - "mcp.tool.read_note", - { - "entrypoint": "mcp", - "tool_name": "read_note", - "requested_project": test_project.name, - "requested_project_id": None, - "page": 1, - "page_size": 10, - "output_format": "json", - "include_frontmatter": True, - "include_content": True, - }, - ) - span_names = [name for name, _ in spans] - assert "api.request.knowledge.resolve_entity" in span_names - assert "api.request.knowledge.get_entity" in span_names - assert "api.request.resource.get_content" not in span_names - assert _contains_span_attrs( - spans, - "routing.client_session", - { - "project_name": test_project.name, - "route_mode": "local_asgi", - }, + spans = _spans(capfire) + # Accepted content comes with the entity, so the resource route is never read. + assert _api_requests(spans) == Counter( + {"api.request.knowledge.resolve_entity": 1, "api.request.knowledge.get_entity": 1} ) + assert _routed_to(spans, test_project.name) @pytest.mark.asyncio -async def test_search_notes_emits_root_operation_and_project_context( - app, test_project, monkeypatch -) -> None: +async def test_bm_edit_resolves_then_patches(app, test_project, capfire: CaptureLogfire) -> None: await bm_write( project=test_project.name, - title="Searchable Telemetry Note", + title="Editable Telemetry Note", directory="notes", - content="Telemetry search content", - tags=["telemetry"], + content="Original telemetry content", ) + capfire.exporter.clear() - spans, fake_span = _recording_spans() - monkeypatch.setattr(logfire, "span", fake_span) - - await search_module.search_notes( + await bm_edit( + "notes/editable-telemetry-note", + command="append", + content="\n\nAppended telemetry content", project=test_project.name, - query="telemetry", - search_type="text", - tags=["telemetry"], output_format="json", ) - assert spans[0] == ( - "mcp.tool.search_notes", - { - "entrypoint": "mcp", - "tool_name": "search_notes", - "requested_project": test_project.name, - "requested_project_id": None, - "search_all_projects": False, - "search_type": "text", - "output_format": "json", - "page": 1, - "page_size": 10, - "has_query": True, - "note_type_filter_count": 0, - "entity_type_filter_count": 0, - "category_filter_count": 0, - "has_filters": True, - "has_tags_filter": True, - "has_status_filter": False, - "has_temporal_filter": False, - }, - ) - span_names = [name for name, _ in spans] - assert "api.request.search" in span_names - assert _contains_span_attrs( - spans, - "routing.client_session", - { - "project_name": test_project.name, - "route_mode": "local_asgi", - }, + spans = _spans(capfire) + assert _api_requests(spans) == Counter( + {"api.request.knowledge.resolve_entity": 1, "api.request.knowledge.edit_entity": 1} ) + assert _routed_to(spans, test_project.name) @pytest.mark.asyncio -async def test_edit_note_emits_root_operation_and_project_context( - app, test_project, monkeypatch +async def test_bm_search_records_the_search_request( + app, test_project, capfire: CaptureLogfire ) -> None: await bm_write( project=test_project.name, - title="Editable Telemetry Note", + title="Searchable Telemetry Note", directory="notes", - content="Original telemetry content", + content="Telemetry search content", + tags=["telemetry"], ) + capfire.exporter.clear() - spans, fake_span = _recording_spans() - monkeypatch.setattr(logfire, "span", fake_span) - - await edit_note_module.edit_note( - "notes/editable-telemetry-note", - operation="append", - content="\n\nAppended telemetry content", - project=test_project.name, - output_format="json", - ) + await bm_search(query="telemetry", mode="text", project=test_project.name, output_format="json") - assert spans[0] == ( - "mcp.tool.edit_note", - { - "entrypoint": "mcp", - "tool_name": "edit_note", - "requested_project": test_project.name, - "requested_project_id": None, - "edit_operation": "append", - "output_format": "json", - "has_section": False, - "has_find_text": False, - "expected_replacements": 1, - "replace_subsections": True, - "has_metadata": False, - "has_expected_checksum": False, - }, - ) - span_names = [name for name, _ in spans] - assert "api.request.knowledge.resolve_entity" in span_names - assert "api.request.knowledge.edit_entity" in span_names - assert _contains_span_attrs( - spans, - "routing.client_session", - { - "project_name": test_project.name, - "route_mode": "local_asgi", - }, - ) + spans = _spans(capfire) + assert _api_requests(spans) == Counter({"api.request.search": 1}) + assert _routed_to(spans, test_project.name) @pytest.mark.asyncio -async def test_bm_context_emits_client_operation_and_project_context( - app, test_project, monkeypatch +async def test_bm_context_records_the_context_request( + app, test_project, capfire: CaptureLogfire ) -> None: await bm_write( project=test_project.name, @@ -213,9 +111,7 @@ async def test_bm_context_emits_client_operation_and_project_context( directory="notes", content="Context telemetry content", ) - - spans, fake_span = _recording_spans() - monkeypatch.setattr(logfire, "span", fake_span) + capfire.exporter.clear() await bm_context( identifier="memory://notes/context-telemetry-note", @@ -228,18 +124,17 @@ async def test_bm_context_emits_client_operation_and_project_context( output_format="json", ) - assert _contains_span_attrs( - spans, - "mcp.client.memory.build_context", - {"client_name": "memory", "operation": "build_context", "page": 1, "page_size": 5}, - ) - span_names = [name for name, _ in spans] - assert "api.request.memory.build_context" in span_names - assert _contains_span_attrs( - spans, - "routing.client_session", - { - "project_name": test_project.name, - "route_mode": "local_asgi", - }, + spans = _spans(capfire) + assert _api_requests(spans) == Counter({"api.request.memory.build_context": 1}) + assert any( + name == "mcp.client.memory.build_context" + and { + "client_name": "memory", + "operation": "build_context", + "page": 1, + "page_size": 5, + }.items() + <= attrs.items() + for name, attrs in spans ) + assert _routed_to(spans, test_project.name) diff --git a/tests/mcp/test_tool_workspace_management.py b/tests/mcp/test_tool_workspace_management.py deleted file mode 100644 index 50e6d8b54..000000000 --- a/tests/mcp/test_tool_workspace_management.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Tests for workspace MCP tools.""" - -import pytest - -from basic_memory.mcp.project_context import get_available_workspaces, set_workspace_provider -from basic_memory.mcp.tools.workspaces import list_workspaces -from basic_memory.schemas.cloud import WorkspaceInfo -from tests.mcp.conftest import ContextState, ctx - - -def _workspace( - *, - tenant_id: str, - workspace_type: str, - name: str, - role: str, - slug: str | None = None, - is_default: bool = False, -) -> WorkspaceInfo: - return WorkspaceInfo( - tenant_id=tenant_id, - workspace_type=workspace_type, - slug=slug or name.casefold().replace(" ", "-"), - name=name, - role=role, - is_default=is_default, - ) - - -@pytest.mark.asyncio -async def test_list_workspaces_formats_workspace_rows(monkeypatch): - async def fake_get_available_workspaces(context=None): - return [ - _workspace( - tenant_id="11111111-1111-1111-1111-111111111111", - workspace_type="personal", - slug="personal", - name="Personal", - role="owner", - is_default=True, - ), - _workspace( - tenant_id="22222222-2222-2222-2222-222222222222", - workspace_type="organization", - slug="team", - name="Team", - role="editor", - ), - ] - - monkeypatch.setattr( - "basic_memory.mcp.tools.workspaces.get_available_workspaces", - fake_get_available_workspaces, - ) - - result = await list_workspaces() - assert "# Available Workspaces (2)" in result - assert "Personal (slug=personal, type=personal, role=owner" in result - assert "Team (slug=team, type=organization, role=editor" in result - - -@pytest.mark.asyncio -async def test_list_workspaces_json_uses_workspace_list_schema(monkeypatch): - async def fake_get_available_workspaces(context=None): - return [ - _workspace( - tenant_id="11111111-1111-1111-1111-111111111111", - workspace_type="personal", - slug="personal", - name="Personal", - role="owner", - is_default=True, - ), - _workspace( - tenant_id="22222222-2222-2222-2222-222222222222", - workspace_type="organization", - slug="team", - name="Team", - role="editor", - ), - ] - - monkeypatch.setattr( - "basic_memory.mcp.tools.workspaces.get_available_workspaces", - fake_get_available_workspaces, - ) - - result = await list_workspaces(output_format="json") - - assert isinstance(result, dict) - assert result["count"] == 2 - assert result["default_workspace_id"] == "11111111-1111-1111-1111-111111111111" - assert result["current_workspace_id"] is None - assert result["workspaces"][0]["slug"] == "personal" - assert result["workspaces"][0]["is_default"] is True - assert result["workspaces"][1]["slug"] == "team" - - -@pytest.mark.asyncio -async def test_list_workspaces_handles_empty_list(monkeypatch): - async def fake_get_available_workspaces(context=None): - return [] - - monkeypatch.setattr( - "basic_memory.mcp.tools.workspaces.get_available_workspaces", - fake_get_available_workspaces, - ) - - result = await list_workspaces() - assert "# Available Workspaces (1)" in result - assert "Personal (slug=personal, type=personal, role=owner, default" in result - - json_result = await list_workspaces(output_format="json") - assert isinstance(json_result, dict) - assert json_result["count"] == 1 - assert json_result["default_workspace_id"] == "personal" - assert json_result["workspaces"][0]["slug"] == "personal" - - -@pytest.mark.asyncio -async def test_list_workspaces_oauth_error_bubbles_up(monkeypatch): - async def fake_get_available_workspaces(context=None): - raise RuntimeError("Workspace discovery requires OAuth login. Run 'bm cloud login' first.") - - monkeypatch.setattr( - "basic_memory.mcp.tools.workspaces.get_available_workspaces", - fake_get_available_workspaces, - ) - - with pytest.raises(RuntimeError, match="Workspace discovery requires OAuth login"): - await list_workspaces() - - -@pytest.mark.asyncio -async def test_list_workspaces_uses_context_cache_path(monkeypatch): - context = ContextState() - call_count = {"fetches": 0} - workspace = _workspace( - tenant_id="33333333-3333-3333-3333-333333333333", - workspace_type="personal", - slug="cached", - name="Cached", - role="owner", - ) - - async def fake_get_available_workspaces(context=None): - assert context is not None - cached = await context.get_state("available_workspaces") - if cached: - return cached - call_count["fetches"] += 1 - await context.set_state("available_workspaces", [workspace]) - return [workspace] - - monkeypatch.setattr( - "basic_memory.mcp.tools.workspaces.get_available_workspaces", - fake_get_available_workspaces, - ) - - first = await list_workspaces(context=ctx(context)) - second = await list_workspaces(context=ctx(context)) - - assert "# Available Workspaces (1)" in first - assert "# Available Workspaces (1)" in second - assert call_count["fetches"] == 1 - - -# --- Workspace provider injection tests --- - - -@pytest.fixture -def _reset_workspace_provider(monkeypatch): - """Ensure _workspace_provider is reset after each test.""" - import basic_memory.mcp.project_context as _mod - - monkeypatch.setattr(_mod, "_workspace_provider", None) - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_reset_workspace_provider") -async def test_get_available_workspaces_uses_provider_when_set(): - """When a workspace provider is injected, it is called instead of the control-plane client.""" - expected = [ - _workspace( - tenant_id="aaaa-bbbb", - workspace_type="personal", - slug="injected", - name="Injected", - role="owner", - is_default=True, - ), - ] - - async def fake_provider() -> list[WorkspaceInfo]: - return expected - - set_workspace_provider(fake_provider) - - result = await get_available_workspaces() - assert len(result) == 1 - assert result[0].tenant_id == "aaaa-bbbb" - assert result[0].name == "Injected" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_reset_workspace_provider") -async def test_get_available_workspaces_falls_back_without_provider(monkeypatch): - """Without a provider, get_available_workspaces uses the control-plane client (existing path).""" - called = {"control_plane": False} - - async def fake_control_plane_path(context=None): - called["control_plane"] = True - return [] - - # Patch the entire function to avoid needing real credentials - monkeypatch.setattr( - "basic_memory.mcp.tools.workspaces.get_available_workspaces", - fake_control_plane_path, - ) - - result = await list_workspaces() - assert called["control_plane"] - assert "# Available Workspaces (1)" in result - assert "Personal (slug=personal, type=personal, role=owner, default" in result - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_reset_workspace_provider") -async def test_get_available_workspaces_provider_caches_in_context(): - """Provider results are cached in the MCP context for subsequent calls.""" - call_count = {"provider": 0} - workspace = _workspace( - tenant_id="cccc-dddd", - workspace_type="organization", - slug="cached-provider", - name="Cached Provider", - role="editor", - ) - - async def counting_provider() -> list[WorkspaceInfo]: - call_count["provider"] += 1 - return [workspace] - - set_workspace_provider(counting_provider) - context = ContextState() - - # First call: provider is invoked, result cached - first = await get_available_workspaces(context=ctx(context)) - assert len(first) == 1 - assert call_count["provider"] == 1 - - # Second call: served from context cache, provider not called again - second = await get_available_workspaces(context=ctx(context)) - assert len(second) == 1 - assert call_count["provider"] == 1 diff --git a/tests/mcp/test_unknown_project_routing.py b/tests/mcp/test_unknown_project_routing.py index 7c376dd40..a5d8c815d 100644 --- a/tests/mcp/test_unknown_project_routing.py +++ b/tests/mcp/test_unknown_project_routing.py @@ -8,17 +8,17 @@ import pytest from fastmcp.exceptions import ToolError -from basic_memory.mcp.tools import read_note, search_notes +from basic_memory.mcp.tools import bm_read, bm_search @pytest.mark.asyncio async def test_unknown_project_reports_not_found_instead_of_credentials(app, test_project): with pytest.raises(ToolError) as read_error: - await read_note("anything", project="nope-nope") + await bm_read("anything", project="nope-nope") assert "not found" in str(read_error.value).lower() assert "credentials" not in str(read_error.value).lower() with pytest.raises(ToolError) as search_error: - await search_notes(query="anything", project="nope-nope") + await bm_search(query="anything", project="nope-nope") assert "not found" in str(search_error.value).lower() assert "credentials" not in str(search_error.value).lower() diff --git a/tests/mcp/test_wiki_after_api_writes.py b/tests/mcp/test_wiki_after_api_writes.py index 67cd148e6..8f695fa28 100644 --- a/tests/mcp/test_wiki_after_api_writes.py +++ b/tests/mcp/test_wiki_after_api_writes.py @@ -13,7 +13,7 @@ LocalWikiState, inspect_local_wiki_projection, ) -from basic_memory.mcp.tools import bm_delete, bm_write, edit_note +from basic_memory.mcp.tools import bm_delete, bm_edit, bm_write from basic_memory.models import AcceptedProjectNoteChange @@ -21,9 +21,7 @@ async def test_wiki_projects_after_api_create_edit_and_delete(app, test_project, session_maker): await bm_write(project=test_project.name, title="Kept", directory="a", content="# Kept") await bm_write(project=test_project.name, title="Gone", directory="a", content="# Gone") - await edit_note( - identifier="a/kept", operation="append", content="More.", project=test_project.name - ) + await bm_edit(identifier="a/kept", command="append", content="More.", project=test_project.name) await bm_delete(identifier="a/gone", project=test_project.name, context=None) async with db.scoped_session(session_maker) as session: diff --git a/tests/mcp/test_workspace_permalink_resolution.py b/tests/mcp/test_workspace_permalink_resolution.py index ea0407b87..a8030a3f5 100644 --- a/tests/mcp/test_workspace_permalink_resolution.py +++ b/tests/mcp/test_workspace_permalink_resolution.py @@ -2,10 +2,7 @@ import pytest -from basic_memory.mcp.tools.delete import bm_delete -from basic_memory.mcp.tools.edit_note import edit_note -from basic_memory.mcp.tools.read_note import read_note -from basic_memory.mcp.tools import bm_write +from basic_memory.mcp.tools import bm_delete, bm_edit, bm_read, bm_write from basic_memory.workspace_context import workspace_permalink_context @@ -36,7 +33,7 @@ async def test_link_resolver_strictly_finds_legacy_project_permalink_from_worksp @pytest.mark.asyncio -async def test_edit_note_workspace_qualified_url_edits_legacy_project_permalink( +async def test_bm_edit_workspace_qualified_url_edits_legacy_project_permalink( app, test_project, ): @@ -52,20 +49,20 @@ async def test_edit_note_workspace_qualified_url_edits_legacy_project_permalink( ) with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"): - edit_result = await edit_note( + edit_result = await bm_edit( identifier=f"memory://{qualified_permalink}", project=test_project.name, - operation="find_replace", + command="find_replace", find_text="status: draft", content="status: done", output_format="json", ) assert isinstance(edit_result, dict) - assert edit_result["fileCreated"] is False + assert edit_result["action"] == "edited" assert edit_result["permalink"] == legacy_permalink - read_result = await read_note( + read_result = await bm_read( legacy_permalink, project=test_project.name, output_format="json", diff --git a/tests/mcp/test_workspace_provider.py b/tests/mcp/test_workspace_provider.py new file mode 100644 index 000000000..12fb2f951 --- /dev/null +++ b/tests/mcp/test_workspace_provider.py @@ -0,0 +1,91 @@ +"""Workspace discovery through an injected workspace provider.""" + +import pytest + +from basic_memory.mcp.project_context import get_available_workspaces, set_workspace_provider +from basic_memory.schemas.cloud import WorkspaceInfo +from tests.mcp.conftest import ContextState, ctx + + +def _workspace( + *, + tenant_id: str, + workspace_type: str, + name: str, + role: str, + slug: str | None = None, + is_default: bool = False, +) -> WorkspaceInfo: + return WorkspaceInfo( + tenant_id=tenant_id, + workspace_type=workspace_type, + slug=slug or name.casefold().replace(" ", "-"), + name=name, + role=role, + is_default=is_default, + ) + + +@pytest.fixture +def _reset_workspace_provider(monkeypatch): + """Ensure _workspace_provider is reset after each test.""" + import basic_memory.mcp.project_context as _mod + + monkeypatch.setattr(_mod, "_workspace_provider", None) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_reset_workspace_provider") +async def test_get_available_workspaces_uses_provider_when_set(): + """When a workspace provider is injected, it is called instead of the control-plane client.""" + expected = [ + _workspace( + tenant_id="aaaa-bbbb", + workspace_type="personal", + slug="injected", + name="Injected", + role="owner", + is_default=True, + ), + ] + + async def fake_provider() -> list[WorkspaceInfo]: + return expected + + set_workspace_provider(fake_provider) + + result = await get_available_workspaces() + assert len(result) == 1 + assert result[0].tenant_id == "aaaa-bbbb" + assert result[0].name == "Injected" + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_reset_workspace_provider") +async def test_get_available_workspaces_provider_caches_in_context(): + """Provider results are cached in the MCP context for subsequent calls.""" + call_count = {"provider": 0} + workspace = _workspace( + tenant_id="cccc-dddd", + workspace_type="organization", + slug="cached-provider", + name="Cached Provider", + role="editor", + ) + + async def counting_provider() -> list[WorkspaceInfo]: + call_count["provider"] += 1 + return [workspace] + + set_workspace_provider(counting_provider) + context = ContextState() + + # First call: provider is invoked, result cached + first = await get_available_workspaces(context=ctx(context)) + assert len(first) == 1 + assert call_count["provider"] == 1 + + # Second call: served from context cache, provider not called again + second = await get_available_workspaces(context=ctx(context)) + assert len(second) == 1 + assert call_count["provider"] == 1 diff --git a/tests/mcp/tools/test_chatgpt_tools.py b/tests/mcp/tools/test_chatgpt_tools.py deleted file mode 100644 index b559ee700..000000000 --- a/tests/mcp/tools/test_chatgpt_tools.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Tests for ChatGPT-compatible MCP tools.""" - -import json -from typing import Any, cast - -import pytest - -from basic_memory.mcp.client_info import MCP_CLIENT_INFO_STATE_KEY -from basic_memory.mcp.tools.write import bm_write -from basic_memory.mcp.refusals import refusal -from basic_memory.schemas.search import SearchResponse, SearchResult, SearchItemType - - -async def _openai_mcp_context(context_state) -> Any: - await context_state.set_state( - MCP_CLIENT_INFO_STATE_KEY, - {"name": "openai-mcp", "title": None, "version": "1.0.0"}, - ) - return cast(Any, context_state) - - -@pytest.mark.asyncio -async def test_search_successful_results(client, test_project, context_state): - """Test search with successful results returns proper MCP content array format.""" - await bm_write( - project=test_project.name, - title="Test Document 1", - directory="docs", - content="# Test Document 1\n\nThis is test content for document 1", - ) - await bm_write( - project=test_project.name, - title="Test Document 2", - directory="docs", - content="# Test Document 2\n\nThis is test content for document 2", - ) - - from basic_memory.mcp.tools.chatgpt_tools import search - - context = await _openai_mcp_context(context_state) - result = await search("test content", context=context) - - # Verify MCP content array format - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["type"] == "text" - - # Parse the JSON content - content = json.loads(result[0]["text"]) - assert "results" in content - assert content["query"] == "test content" - - # Verify individual result format - assert any(r["id"] == f"{test_project.name}/docs/test-document-1" for r in content["results"]) - assert any(r["id"] == f"{test_project.name}/docs/test-document-2" for r in content["results"]) - - -@pytest.mark.asyncio -async def test_search_with_error_response(monkeypatch, client, test_project, context_state): - """Test search when underlying bm_search returns an error string.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - error_message = "# Search Failed - Invalid Syntax\n\nThe search query contains errors..." - - async def fake_bm_search_fn(*args, **kwargs): - raise refusal("INVALID_ARGUMENTS", error_message) - - monkeypatch.setattr(chatgpt_tools, "bm_search", fake_bm_search_fn) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.search("invalid query", context=context) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["type"] == "text" - - content = json.loads(result[0]["text"]) - assert content["results"] == [] - assert content["error"] == "Search failed" - assert content["error_message"] == error_message - - -@pytest.mark.asyncio -async def test_search_retryable_outage_returns_explicit_retry_message( - monkeypatch, client, test_project, context_state -): - """The bm_search 503 shape remains retryable through the ChatGPT adapter.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - async def fake_bm_search_fn(*args, **kwargs): - raise refusal("UNAVAILABLE", "Reranker temporarily unavailable") - - monkeypatch.setattr(chatgpt_tools, "bm_search", fake_bm_search_fn) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.search("retryable query", context=context) - - content = json.loads(result[0]["text"]) - assert content == { - "results": [], - "error": "Search temporarily unavailable", - "error_message": "Search temporarily unavailable, retry shortly", - } - - -@pytest.mark.asyncio -async def test_search_uses_dynamic_default_mode(monkeypatch, client, test_project, context_state): - """ChatGPT adapter should not hardcode mode so bm_search can pick defaults.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - captured_kwargs: dict[str, Any] = {} - - async def fake_bm_search_fn(*args, **kwargs): - captured_kwargs.update(kwargs) - return {"results": []} - - monkeypatch.setattr(chatgpt_tools, "bm_search", fake_bm_search_fn) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.search("default search mode query", context=context) - - assert isinstance(result, list) - assert "mode" not in captured_kwargs - - -@pytest.mark.asyncio -async def test_search_delegates_to_bm_search_without_project_iteration( - monkeypatch, client, test_project, context_state -): - """ChatGPT search is only a compatibility wrapper around bm_search.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - captured_kwargs: dict[str, Any] = {} - - async def fake_bm_search_fn(*args, **kwargs): - captured_kwargs.update(kwargs) - return {"results": []} - - monkeypatch.setattr(chatgpt_tools, "bm_search", fake_bm_search_fn) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.search("MCP Test Note", context=context) - - content = json.loads(result[0]["text"]) - assert content["results"] == [] - assert content["query"] == "MCP Test Note" - assert captured_kwargs == { - "project": test_project.name, - "query": "MCP Test Note", - "page": 1, - "page_size": 10, - "output_format": "json", - "context": context, - } - - -@pytest.mark.asyncio -async def test_fetch_successful_document(client, test_project, context_state): - """Test fetch with successful document retrieval.""" - await bm_write( - project=test_project.name, - title="Test Document", - directory="docs", - content="# Test Document\n\nThis is the content of a test document.", - ) - - from basic_memory.mcp.tools.chatgpt_tools import fetch - - context = await _openai_mcp_context(context_state) - result = await fetch("docs/test-document", context=context) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["type"] == "text" - - content = json.loads(result[0]["text"]) - assert content["id"] == "docs/test-document" - assert content["title"] == "Test Document" - assert "This is the content of a test document." in content["text"] - assert content["url"] == "docs/test-document" - assert content["metadata"]["format"] == "markdown" - - -@pytest.mark.asyncio -async def test_fetch_document_not_found(client, test_project, context_state): - """Test fetch when document is not found.""" - from basic_memory.mcp.tools.chatgpt_tools import fetch - - context = await _openai_mcp_context(context_state) - result = await fetch("nonexistent-doc", context=context) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["type"] == "text" - - content = json.loads(result[0]["text"]) - assert content["id"] == "nonexistent-doc" - assert content["metadata"]["error"] == "Document not found" - - -@pytest.mark.asyncio -async def test_fetch_routes_path_ids_as_memory_urls( - monkeypatch, client, test_project, context_state -): - """Workspace-qualified search ids need memory URL routing during fetch.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - captured: dict[str, str] = {} - - async def fake_bm_read( - *, identifier: str, include_frontmatter: bool, project=None, context=None - ): - captured["identifier"] = identifier - return "# MCP Test Note\n\nFetched from the requested workspace." - - monkeypatch.setattr(chatgpt_tools, "bm_read", fake_bm_read) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.fetch("team-paul/main/tests/mcp-test-note", context=context) - - content = json.loads(result[0]["text"]) - assert captured["identifier"] == "memory://team-paul/main/tests/mcp-test-note" - assert content["id"] == "team-paul/main/tests/mcp-test-note" - assert content["title"] == "MCP Test Note" - - -def test_format_search_results_for_chatgpt(): - """Test search results formatting.""" - from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt - - mock_results = SearchResponse( - results=[ - SearchResult( - title="Document One", - permalink="docs/doc-one", - content="Content for document one", - type=SearchItemType.ENTITY, - score=1.0, - file_path="/test/docs/doc-one.md", - ), - SearchResult( - title="", # Test empty title handling - permalink="docs/untitled", - content="Content without title", - type=SearchItemType.ENTITY, - score=0.8, - file_path="/test/docs/untitled.md", - ), - ], - current_page=1, - page_size=10, - ) - - formatted = _format_search_results_for_chatgpt(mock_results) - - assert len(formatted) == 2 - assert formatted[0]["id"] == "docs/doc-one" - assert formatted[0]["title"] == "Document One" - assert formatted[0]["url"] == "docs/doc-one" - - # Test empty title handling - assert formatted[1]["title"] == "Untitled" - - -def test_format_search_results_for_chatgpt_handles_dict_payloads(): - """Dict payloads must still normalize to the ChatGPT result array shape.""" - from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt - - formatted = _format_search_results_for_chatgpt( - {"results": [{"title": "Document One", "permalink": "docs/doc-one"}]} - ) - - assert formatted == [ - { - "id": "docs/doc-one", - "title": "Document One", - "url": "docs/doc-one", - } - ] - assert _format_search_results_for_chatgpt({"results": "not a list"}) == [] - - -def test_format_search_results_for_chatgpt_rejects_unknown_rows(): - """Unexpected row shapes should fail loudly instead of producing bad IDs.""" - from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt - - with pytest.raises(TypeError, match="Unexpected result type: object"): - _format_search_results_for_chatgpt(cast(Any, [object()])) - - -def test_format_document_for_chatgpt(): - """Test document formatting.""" - from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt - - content = "# Test Document\n\nThis is test content." - result = _format_document_for_chatgpt(content, "docs/test") - - assert result["id"] == "docs/test" - assert result["title"] == "Test Document" - assert result["text"] == content - assert result["url"] == "docs/test" - assert result["metadata"]["format"] == "markdown" - - -def test_format_document_error_handling(): - """Test document formatting with error content.""" - from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt - - error_content = '# Note Not Found: "missing-doc"\n\nDocument not found.' - result = _format_document_for_chatgpt(error_content, "missing-doc", "Missing Doc") - - assert result["id"] == "missing-doc" - assert result["title"] == "Missing Doc" - assert result["text"] == error_content - assert result["metadata"]["format"] == "markdown" - - -def test_format_document_untitled_fallback_for_empty_identifier(): - """If identifier is empty and content has no H1, we still return a stable title.""" - from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt - - result = _format_document_for_chatgpt("no title here", "") - assert result["title"] == "Untitled Document" - - -@pytest.mark.asyncio -async def test_search_internal_exception_returns_error_payload( - monkeypatch, client, test_project, context_state -): - """search() should return a structured error payload if an unexpected exception occurs.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - async def boom(*args, **kwargs): - raise RuntimeError("boom") - - monkeypatch.setattr(chatgpt_tools, "bm_search", boom) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.search("anything", context=context) - assert isinstance(result, list) - content = json.loads(result[0]["text"]) - assert content["error"] == "Internal search error" - assert content["error_message"] == "boom" - - -@pytest.mark.asyncio -async def test_fetch_internal_exception_returns_error_payload( - monkeypatch, client, test_project, context_state -): - """fetch() should return a structured error payload if an unexpected exception occurs.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - async def boom(*args, **kwargs): - raise RuntimeError("boom") - - monkeypatch.setattr(chatgpt_tools, "bm_read", boom) - - context = await _openai_mcp_context(context_state) - result = await chatgpt_tools.fetch("docs/test", context=context) - assert isinstance(result, list) - content = json.loads(result[0]["text"]) - assert content["id"] == "docs/test" - assert content["metadata"]["error"] == "Fetch failed" - - -@pytest.mark.asyncio -async def test_search_rejects_non_openai_mcp_client(monkeypatch, context_state): - """Only OpenAI's MCP client can use the ChatGPT compatibility search tool.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - called = False - - async def fake_bm_search_fn(*args, **kwargs): - nonlocal called - called = True - return {"results": []} - - await context_state.set_state( - MCP_CLIENT_INFO_STATE_KEY, - {"name": "claude-code", "title": "Claude Code", "version": "1.2.3"}, - ) - monkeypatch.setattr(chatgpt_tools, "bm_search", fake_bm_search_fn) - - result = await chatgpt_tools.search("anything", context=cast(Any, context_state)) - - content = json.loads(result[0]["text"]) - assert called is False - assert content["results"] == [] - assert content["error"] == "Unsupported MCP client" - assert "bm_search" in content["error_message"] - - -@pytest.mark.asyncio -async def test_fetch_rejects_missing_client_info(monkeypatch, context_state): - """A missing clientInfo state is not enough to use the ChatGPT fetch shim.""" - import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - - called = False - - async def fake_bm_read(*args, **kwargs): - nonlocal called - called = True - return "# Should Not Be Read" - - monkeypatch.setattr(chatgpt_tools, "bm_read", fake_bm_read) - - result = await chatgpt_tools.fetch("docs/test", context=cast(Any, context_state)) - - content = json.loads(result[0]["text"]) - assert called is False - assert content["id"] == "docs/test" - assert content["metadata"]["error"] == "Unsupported MCP client" - assert "bm_read" in content["text"] diff --git a/tests/services/test_link_resolver.py b/tests/services/test_link_resolver.py index 8b3d93911..cb5fdb584 100644 --- a/tests/services/test_link_resolver.py +++ b/tests/services/test_link_resolver.py @@ -2,7 +2,6 @@ import uuid from datetime import datetime, timezone -from types import SimpleNamespace import pytest @@ -125,190 +124,6 @@ def project_prefix(test_entities) -> str: return test_entities[0].permalink.split("/", 1)[0] -def test_workspace_qualified_plain_identifier_shape_helper_lives_in_link_resolver(): - """Workspace route shape detection belongs with permalink/link resolution.""" - from basic_memory.services import link_resolver - - assert link_resolver.is_workspace_qualified_plain_identifier("team-acme/research/note") - assert link_resolver.is_workspace_qualified_plain_identifier("team-acme/research/folder/note") - assert not link_resolver.is_workspace_qualified_plain_identifier( - "memory://team-acme/research/note" - ) - assert not link_resolver.is_workspace_qualified_plain_identifier("research/note") - - -@pytest.mark.asyncio -async def test_workspace_identifier_project_detection_requires_workspace_shape( - monkeypatch, - config_manager, -): - """Two-segment project-relative paths should not trigger workspace discovery.""" - from basic_memory.services import link_resolver - - def fail_if_called(identifier, config): - raise AssertionError("plain project-relative paths should skip cloud discovery") - - monkeypatch.setattr( - "basic_memory.mcp.project_context._workspace_identifier_discovery_available", - fail_if_called, - ) - - detected = await link_resolver.detect_project_from_workspace_identifier_prefix( - "research/note", - config_manager.config, - ) - - assert detected is None - - -@pytest.mark.asyncio -async def test_workspace_identifier_project_detection_skips_without_discovery( - monkeypatch, - config_manager, -): - """Workspace-shaped paths stay local when cloud workspace discovery is unavailable.""" - from basic_memory.services import link_resolver - - monkeypatch.setattr( - "basic_memory.mcp.project_context._workspace_identifier_discovery_available", - lambda identifier, config: False, - ) - - detected = await link_resolver.detect_project_from_workspace_identifier_prefix( - "team-acme/research/note", - config_manager.config, - ) - - assert detected is None - - -@pytest.mark.asyncio -async def test_workspace_identifier_project_detection_returns_project( - monkeypatch, - config_manager, -): - """Workspace-qualified plain identifiers should return the resolved project route.""" - from basic_memory.services import link_resolver - - async def resolve_workspace_identifier(identifier, context=None): - assert identifier == "team-acme/research/note" - return SimpleNamespace(project_identifier="team-acme/research") - - monkeypatch.setattr( - "basic_memory.mcp.project_context._workspace_identifier_discovery_available", - lambda identifier, config: True, - ) - monkeypatch.setattr( - "basic_memory.mcp.project_context.resolve_workspace_qualified_identifier", - resolve_workspace_identifier, - ) - - detected = await link_resolver.detect_project_from_workspace_identifier_prefix( - "team-acme/research/note", - config_manager.config, - ) - - assert detected == "team-acme/research" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "message", - [ - "Workspace 'team-acme' was not found.", - "No accessible workspaces found for this account.", - "Unable to discover projects in any accessible workspace. Failed workspaces: team-acme", - ], -) -async def test_workspace_identifier_project_detection_ignores_discovery_failures( - monkeypatch, - config_manager, - message, -): - """Workspace detection should fall back when cloud discovery is unavailable.""" - from basic_memory.services import link_resolver - - async def fail_workspace_identifier(identifier, context=None): - raise ValueError(message) - - monkeypatch.setattr( - "basic_memory.mcp.project_context._workspace_identifier_discovery_available", - lambda identifier, config: True, - ) - monkeypatch.setattr( - "basic_memory.mcp.project_context.resolve_workspace_qualified_identifier", - fail_workspace_identifier, - ) - - detected = await link_resolver.detect_project_from_workspace_identifier_prefix( - "team-acme/research/note", - config_manager.config, - ) - - assert detected is None - - -@pytest.mark.asyncio -async def test_workspace_identifier_project_detection_allows_mixed_local_cloud_config( - monkeypatch, - config_manager, -): - """Plain workspace routes should be discoverable even with local projects configured.""" - from basic_memory.config import ProjectEntry - from basic_memory.mcp.project_context import ( - WorkspaceProjectEntry, - _build_workspace_project_index, - ) - from basic_memory.schemas.cloud import WorkspaceInfo - from basic_memory.schemas.project_info import ProjectItem - from basic_memory.services import link_resolver - - config = config_manager.load_config() - config.projects["hermes-memory"] = ProjectEntry( - path=str(config_manager.config_dir.parent / "hermes-memory") - ) - config.cloud_api_key = "bmc_test123" - config_manager.save_config(config) - - workspace = WorkspaceInfo( - tenant_id="personal-tenant", - workspace_type="personal", - slug="personal", - name="Personal", - role="owner", - is_default=True, - ) - project = ProjectItem( - id=1, - external_id="11111111-1111-1111-1111-111111111111", - name="main", - path="/tmp/main", - is_default=False, - ) - index = _build_workspace_project_index( - (workspace,), - (WorkspaceProjectEntry(workspace=workspace, project=project),), - ) - - async def fake_index(context=None): - return index - - monkeypatch.setattr( - "basic_memory.mcp.project_context._ensure_workspace_project_index", - fake_index, - ) - monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False) - monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False) - monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False) - - detected = await link_resolver.detect_project_from_workspace_identifier_prefix( - "personal/main/todo", - config_manager.config, - ) - - assert detected == "personal/main" - - @pytest.mark.asyncio async def test_exact_permalink_match(link_resolver, test_entities, project_prefix): """Test resolving a link that exactly matches a permalink.""" From 9fc53d65f5a048e6f299787c1b4c1b42078ddd76 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 29 Oct 2026 13:51:43 -0500 Subject: [PATCH 2/2] ci: point the SQLite lifecycle probe at bm_search pagination The probe ran the deleted ChatGPT search pagination test. test_search_pagination does the same through the production MCP server: 15 bm_write calls, then paged bm_search on SQLite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CMYVL1x91B8kJxy7nn2GU8 Signed-off-by: phernandez --- .github/workflows/sqlite-lifecycle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sqlite-lifecycle.yml b/.github/workflows/sqlite-lifecycle.yml index 624585a6f..62c3566c2 100644 --- a/.github/workflows/sqlite-lifecycle.yml +++ b/.github/workflows/sqlite-lifecycle.yml @@ -10,7 +10,7 @@ on: - 'src/basic_memory/**' - 'pyproject.toml' - 'uv.lock' - - 'test-int/mcp/test_chatgpt_tools_integration.py' + - 'test-int/mcp/test_search_integration.py' - 'test-int/mcp/test_concurrent_write_integration.py' permissions: @@ -55,7 +55,7 @@ jobs: shell: bash run: >- uv run pytest - test-int/mcp/test_chatgpt_tools_integration.py::test_chatgpt_search_pagination_default + test-int/mcp/test_search_integration.py::test_search_pagination test-int/mcp/test_concurrent_write_integration.py::test_concurrent_write_high_volume -v -s --no-cov --timeout=120 --timeout-method=thread -o faulthandler_timeout=60 --junitxml=sqlite-mcp.xml