11"""Read note tool for Basic Memory MCP server."""
22
33from textwrap import dedent
4- from typing import Optional , Literal , cast
4+ from typing import Annotated , Optional , Literal , cast
55
66import logfire
77import yaml
88
99from loguru import logger
1010from fastmcp import Context
11+ from pydantic import AliasChoices , Field
1112
1213from basic_memory .config import ConfigManager
1314from basic_memory .mcp .project_context import (
2021from basic_memory .schemas .memory import memory_url_path
2122from basic_memory .utils import validate_project_path
2223
24+ # The title-match fallback exists to find THE note by exact title, so it scans
25+ # fixed-size pages of title results instead of the caller's page/page_size
26+ # (which apply only to the text-search suggestion listing).
27+ _TITLE_LOOKUP_PAGE_SIZE = 10
28+
29+ # Hard safety cap on title-lookup pages. The loop normally stops as soon as an
30+ # exact match is found or results run out (has_more=False); the cap only bounds
31+ # pathological knowledge bases where hundreds of fuzzy titles contain the
32+ # queried phrase. Exhausting the cap falls through to the suggestion behavior.
33+ _TITLE_LOOKUP_MAX_PAGES = 10
34+
2335
2436def _is_exact_title_match (identifier : str , title : str ) -> bool :
2537 """Return True when identifier exactly matches a title (case-insensitive)."""
@@ -71,6 +83,21 @@ async def read_note(
7183 identifier : str ,
7284 project : Optional [str ] = None ,
7385 project_id : Optional [str ] = None ,
86+ # Accept common pagination aliases models reach for from training data
87+ # (page_number/limit/per_page), matching the sibling navigation tools
88+ # (search_notes, build_context, recent_activity). The schema advertises
89+ # only the canonical names; aliases are silently mapped at validation time.
90+ # `offset` is intentionally NOT aliased: offset is item-indexed (skip N
91+ # items) while page is a 1-indexed page-number, so direct aliasing would
92+ # return the wrong slice.
93+ page : Annotated [
94+ int ,
95+ Field (default = 1 , validation_alias = AliasChoices ("page" , "page_number" )),
96+ ] = 1 ,
97+ page_size : Annotated [
98+ int ,
99+ Field (default = 10 , validation_alias = AliasChoices ("page_size" , "limit" , "per_page" )),
100+ ] = 10 ,
74101 output_format : Literal ["text" , "json" ] = "text" ,
75102 include_frontmatter : bool = False ,
76103 context : Context | None = None ,
@@ -99,6 +126,14 @@ async def read_note(
99126 workspaces. Takes precedence over `project`. Get from list_memory_projects().
100127 identifier: The title or permalink of the note to read
101128 Can be a full memory:// URL, a permalink, a title, or search text
129+ page: Page of fallback-search results to use when the identifier does not
130+ resolve to a note directly (default: 1). A direct or exact-title match
131+ always returns the full note content — page/page_size never chunk the
132+ note itself, and the title-match lookup pages through fixed-size pages
133+ of title results until an exact match is found or results are
134+ exhausted, regardless of page or page_size.
135+ page_size: Number of fallback-search results per page (default: 10). When no
136+ match is found, this caps how many related-note suggestions are listed.
102137 output_format: "text" returns markdown content or guidance text.
103138 "json" returns a structured object with title/permalink/file_path/content/frontmatter.
104139 include_frontmatter: When output_format="json", whether content should include the
@@ -122,6 +157,9 @@ async def read_note(
122157 # Read recent meeting notes
123158 read_note("team-docs", "Weekly Standup")
124159
160+ # Page through fallback-search suggestions when nothing matches directly
161+ read_note("unknown topic", page=2, page_size=5)
162+
125163 Raises:
126164 HTTPError: If project doesn't exist or is inaccessible
127165 SecurityError: If identifier attempts path traversal
@@ -130,6 +168,15 @@ async def read_note(
130168 If the exact note isn't found, this tool provides helpful suggestions
131169 including related notes, search commands, and note creation templates.
132170 """
171+ # Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative).
172+ # Why: both flow into the fallback search's server-side slicing, where
173+ # non-positive values produce empty result pages with unreachable
174+ # pagination. Fail fast, matching search_notes/build_context.
175+ if page < 1 :
176+ raise ValueError (f"page must be >= 1, got { page } " )
177+ if page_size < 1 :
178+ raise ValueError (f"page_size must be >= 1, got { page_size } " )
179+
133180 # Detect project from a memory URL or permalink prefix before routing.
134181 # project_id routes by external UUID, so it bypasses URL discovery entirely.
135182 if project is None and project_id is None :
@@ -147,6 +194,8 @@ async def read_note(
147194 tool_name = "read_note" ,
148195 requested_project = project ,
149196 requested_project_id = project_id ,
197+ page = page ,
198+ page_size = page_size ,
150199 output_format = output_format ,
151200 include_frontmatter = include_frontmatter ,
152201 ):
@@ -245,7 +294,7 @@ def _search_results(payload: object) -> list[dict[str, object]]:
245294 ]
246295
247296 async def _search_candidates (
248- identifier_text : str , * , title_only : bool
297+ identifier_text : str , * , title_only : bool , lookup_page : int = 1
249298 ) -> dict [str , object ]:
250299 # Trigger: direct entity resolution failed for the caller's identifier.
251300 # Why: search_notes applies the same memory:// normalization and tool-level
@@ -256,11 +305,24 @@ async def _search_candidates(
256305 # Without this, project names that collide across workspaces could re-resolve
257306 # to a different tenant via the default-workspace fallback (CLI/context=None).
258307 search_type = "title" if title_only else "text"
308+ # Trigger: title_only — the title search exists to find THE note by
309+ # exact title, not to page through suggestions.
310+ # Why: paginating it by the caller's page would skip an exact match
311+ # sitting on page 1 (read_note("Exact Title", page=2)), and a
312+ # small caller page_size could let a higher-ranked fuzzy title
313+ # displace the exact match out of the lookup window
314+ # (read_note("Foo Bar", page_size=1) when "Foo Bar Foo Bar"
315+ # ranks first) — both returning suggestions instead of the note.
316+ # Outcome: title lookup uses its own lookup_page with a fixed lookup
317+ # size, walked by the caller below; caller page/page_size
318+ # apply only to the text-search suggestion listing.
259319 response = await search_notes (
260320 project = active_project .name ,
261321 project_id = active_project .external_id ,
262322 query = identifier_text ,
263323 search_type = search_type ,
324+ page = lookup_page if title_only else page ,
325+ page_size = _TITLE_LOOKUP_PAGE_SIZE if title_only else page_size ,
264326 output_format = "json" ,
265327 context = context ,
266328 )
@@ -297,12 +359,24 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
297359 logger .info (f"Direct lookup failed for '{ entity_path } ': { e } " )
298360 # Continue to fallback methods
299361
300- # Fallback 1: Try title search via API
362+ # Fallback 1: Try title search via API, walking fixed-size pages of
363+ # title results until an exact match is found or results run out.
364+ # A single page is not enough: when more than _TITLE_LOOKUP_PAGE_SIZE
365+ # higher-ranked fuzzy titles contain the queried phrase, the exact
366+ # title lands on a later page and a one-page lookup would miss it.
301367 logger .info (f"Search title for: { identifier } " )
302- title_results = await _search_candidates (identifier , title_only = True )
303-
304- title_candidates = _search_results (title_results )
305- if title_candidates :
368+ result : dict [str , object ] | None = None
369+ for lookup_page in range (1 , _TITLE_LOOKUP_MAX_PAGES + 1 ):
370+ title_results = await _search_candidates (
371+ identifier , title_only = True , lookup_page = lookup_page
372+ )
373+ title_candidates = _search_results (title_results )
374+ if not title_candidates :
375+ logger .info (
376+ f"No results in title search for: { identifier } "
377+ f"in project { active_project .name } "
378+ )
379+ break
306380 # Trigger: direct resolution failed and title search returned candidates.
307381 # Why: avoid returning unrelated notes when search yields only fuzzy matches.
308382 # Outcome: fetch content only when a true exact title match exists.
@@ -314,33 +388,37 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
314388 ),
315389 None ,
316390 )
317- if not result :
391+ if result is not None :
392+ break
393+ # Trigger: this page held only fuzzy titles and the server reports
394+ # no further pages (has_more is False or absent).
395+ # Why: continuing past the last page would issue empty lookups.
396+ # Outcome: give up on the title fallback and try text search below.
397+ if title_results .get ("has_more" ) is not True :
318398 logger .info (f"No exact title match found for: { identifier } " )
319- elif _result_permalink (result ):
320- try :
321- # Resolve the permalink to entity ID
322- entity_id = await knowledge_client .resolve_entity (
323- _result_permalink (result ) or "" , strict = True
324- )
399+ break
400+
401+ if result is not None and _result_permalink (result ):
402+ try :
403+ # Resolve the permalink to entity ID
404+ entity_id = await knowledge_client .resolve_entity (
405+ _result_permalink (result ) or "" , strict = True
406+ )
407+
408+ # Fetch content using the entity ID
409+ response = await resource_client .read (entity_id )
325410
326- # Fetch content using the entity ID
327- response = await resource_client .read (entity_id )
328-
329- if response .status_code == 200 :
330- logger .info (
331- f"Found note by exact title search: { _result_permalink (result )} "
332- )
333- if output_format == "json" :
334- return await _read_json_payload (entity_id )
335- return response .text
336- except Exception as e : # pragma: no cover
411+ if response .status_code == 200 :
337412 logger .info (
338- f"Failed to fetch content for found title match { _result_permalink (result )} : { e } "
413+ f"Found note by exact title search: { _result_permalink (result )} "
339414 )
340- else :
341- logger .info (
342- f"No results in title search for: { identifier } in project { active_project .name } "
343- )
415+ if output_format == "json" :
416+ return await _read_json_payload (entity_id )
417+ return response .text
418+ except Exception as e : # pragma: no cover
419+ logger .info (
420+ f"Failed to fetch content for found title match { _result_permalink (result )} : { e } "
421+ )
344422
345423 # Fallback 2: Text search as a last resort
346424 logger .info (f"Title search failed, trying text search for: { identifier } " )
@@ -352,6 +430,9 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
352430 if output_format == "json" :
353431 return _empty_json_payload ()
354432 return format_not_found_message (active_project .name , identifier )
433+ # The fallback search is paginated server-side to page_size, so list
434+ # the whole returned page instead of a hardcoded cap — otherwise the
435+ # caller's page_size would be silently ignored past the cap.
355436 if output_format == "json" :
356437 payload = _empty_json_payload ()
357438 payload ["related_results" ] = [
@@ -360,10 +441,10 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
360441 "permalink" : _result_permalink (result ),
361442 "file_path" : _result_file_path (result ),
362443 }
363- for result in text_candidates [: 5 ]
444+ for result in text_candidates
364445 ]
365446 return payload
366- return format_related_results (active_project .name , identifier , text_candidates [: 5 ] )
447+ return format_related_results (active_project .name , identifier , text_candidates )
367448
368449
369450def format_not_found_message (project : str | None , identifier : str ) -> str :
0 commit comments