Skip to content

Commit 7b77aca

Browse files
phernandezclaude
andcommitted
fix(mcp): use fixed lookup size for read_note title fallback
The title-search fallback in read_note used the caller's page_size for the lookup window, so a small suggestion page could let a higher-ranked fuzzy title displace the exact title match: with notes "Foo Bar Foo Bar" and "Foo Bar", read_note("Foo Bar", page_size=1) returned not-found/suggestions even though the note exists. The page=1 pin from 9e8436f had the same problem on the page_size axis. Pin the title lookup to a fixed _TITLE_LOOKUP_PAGE_SIZE window; caller page/page_size keep applying only to the text-search suggestion path. Updates the docstring and decision-point comment to cover both axes, and adds a regression test for the small-page_size displacement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent b559ea0 commit 7b77aca

2 files changed

Lines changed: 64 additions & 10 deletions

File tree

src/basic_memory/mcp/tools/read_note.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
from basic_memory.schemas.memory import memory_url_path
2222
from basic_memory.utils import validate_project_path
2323

24+
# The title-match fallback exists to find THE note by exact title, so it always
25+
# scans a fixed-size first page of title results instead of the caller's
26+
# page/page_size (which apply only to the text-search suggestion listing).
27+
_TITLE_LOOKUP_PAGE_SIZE = 10
28+
2429

2530
def _is_exact_title_match(identifier: str, title: str) -> bool:
2631
"""Return True when identifier exactly matches a title (case-insensitive)."""
@@ -118,8 +123,8 @@ async def read_note(
118123
page: Page of fallback-search results to use when the identifier does not
119124
resolve to a note directly (default: 1). A direct or exact-title match
120125
always returns the full note content — page/page_size never chunk the
121-
note itself, and the title-match lookup always checks the first page
122-
of title results regardless of this value.
126+
note itself, and the title-match lookup always checks a fixed-size
127+
first page of title results regardless of page or page_size.
123128
page_size: Number of fallback-search results per page (default: 10). When no
124129
match is found, this caps how many related-note suggestions are listed.
125130
output_format: "text" returns markdown content or guidance text.
@@ -296,17 +301,21 @@ async def _search_candidates(
296301
# Trigger: title_only — the title search exists to find THE note by
297302
# exact title, not to page through suggestions.
298303
# Why: paginating it by the caller's page would skip an exact match
299-
# sitting on page 1 (read_note("Exact Title", page=2)) and
300-
# return unrelated suggestions instead of the note.
301-
# Outcome: title lookup is pinned to page 1; caller paging applies
302-
# only to the text-search suggestion listing.
304+
# sitting on page 1 (read_note("Exact Title", page=2)), and a
305+
# small caller page_size could let a higher-ranked fuzzy title
306+
# displace the exact match out of the lookup window
307+
# (read_note("Foo Bar", page_size=1) when "Foo Bar Foo Bar"
308+
# ranks first) — both returning suggestions instead of the note.
309+
# Outcome: title lookup is pinned to page 1 with a fixed lookup
310+
# size; caller page/page_size apply only to the
311+
# text-search suggestion listing.
303312
response = await search_notes(
304313
project=active_project.name,
305314
project_id=active_project.external_id,
306315
query=identifier_text,
307316
search_type=search_type,
308317
page=1 if title_only else page,
309-
page_size=page_size,
318+
page_size=_TITLE_LOOKUP_PAGE_SIZE if title_only else page_size,
310319
output_format="json",
311320
context=context,
312321
)

tests/mcp/test_tool_read_note.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,10 @@ async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
176176

177177
result = await read_note("missing-note", project=test_project.name, page=2, page_size=3)
178178

179-
# Title lookup is pinned to page 1 (it exists to find THE note by exact
180-
# title); caller paging applies only to the text-search suggestions.
181-
assert captured_pages == [("title", 1, 3), ("text", 2, 3)]
179+
# Title lookup is pinned to page 1 with a fixed lookup size (it exists to
180+
# find THE note by exact title); caller page/page_size apply only to the
181+
# text-search suggestions.
182+
assert captured_pages == [("title", 1, 10), ("text", 2, 3)]
182183
assert "Note Not Found" in result
183184

184185

@@ -219,6 +220,50 @@ async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
219220
assert "paged title content" in content
220221

221222

223+
@pytest.mark.asyncio
224+
async def test_read_note_title_fallback_finds_exact_match_with_small_page_size(
225+
monkeypatch, app, test_project
226+
):
227+
"""An exact title match is returned even when the caller asks for a tiny page_size.
228+
229+
The title-match lookup uses a fixed lookup size; without it, a higher-ranked
230+
fuzzy title ("Foo Bar Foo Bar") would displace the exact title ("Foo Bar")
231+
out of a page_size=1 window and read_note("Foo Bar", page_size=1) would
232+
return suggestions instead of the note.
233+
"""
234+
await write_note(
235+
project=test_project.name,
236+
title="Foo Bar Foo Bar",
237+
directory="test",
238+
content="fuzzy decoy content",
239+
)
240+
await write_note(
241+
project=test_project.name,
242+
title="Foo Bar",
243+
directory="test",
244+
content="exact title content",
245+
)
246+
247+
import importlib
248+
from basic_memory.schemas.memory import memory_url_path
249+
250+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
251+
OriginalKnowledgeClient = clients_mod.KnowledgeClient
252+
direct_identifier = memory_url_path("Foo Bar")
253+
254+
class SelectiveKnowledgeClient(OriginalKnowledgeClient):
255+
async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str:
256+
# Fail on the direct identifier to force fallback to title search
257+
if identifier == direct_identifier:
258+
raise RuntimeError("force direct lookup failure")
259+
return await super().resolve_entity(identifier, strict=strict)
260+
261+
monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient)
262+
263+
content = await read_note("Foo Bar", project=test_project.name, page_size=1)
264+
assert "exact title content" in content
265+
266+
222267
@pytest.mark.asyncio
223268
async def test_read_note_related_results_list_full_search_page(monkeypatch, app, test_project):
224269
"""Suggestions list the whole returned search page instead of a hardcoded cap of 5."""

0 commit comments

Comments
 (0)