Skip to content

Commit c4b651f

Browse files
authored
fix(mcp): accept page/page_size in read_note for parity with sibling tools (#933)
Closes #883 Refs #882 Signed-off-by: phernandez <paul@basicmemory.com>
1 parent ca9a4d9 commit c4b651f

6 files changed

Lines changed: 471 additions & 42 deletions

File tree

src/basic_memory/mcp/tools/read_note.py

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Read note tool for Basic Memory MCP server."""
22

33
from textwrap import dedent
4-
from typing import Optional, Literal, cast
4+
from typing import Annotated, Optional, Literal, cast
55

66
import logfire
77
import yaml
88

99
from loguru import logger
1010
from fastmcp import Context
11+
from pydantic import AliasChoices, Field
1112

1213
from basic_memory.config import ConfigManager
1314
from basic_memory.mcp.project_context import (
@@ -20,6 +21,17 @@
2021
from basic_memory.schemas.memory import memory_url_path
2122
from 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

2436
def _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

369450
def format_not_found_message(project: str | None, identifier: str) -> str:

src/basic_memory/mcp/tools/write_note.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,12 @@ async def write_note(
8787
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
8888
Examples: "notes", "projects/2025", "research/ml", "/" (root)
8989
project: Project name to write to. Optional - server will resolve using the
90-
hierarchy above. If unknown, use list_memory_projects() to discover
91-
available projects.
90+
hierarchy above. Use "workspace/project" to route to a project in a
91+
specific cloud workspace. A bare name that exists in multiple
92+
workspaces resolves to the default workspace, so use the qualified
93+
form (or project_id) to disambiguate. If unknown, use
94+
list_memory_projects() to discover available projects and their
95+
qualified names.
9296
project_id: Project external_id (UUID). Prefer this over `project` when known —
9397
it routes to the exact project regardless of name collisions across cloud
9498
workspaces. Takes precedence over `project`. Get from list_memory_projects().

test-int/mcp/test_param_aliases_integration.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,49 @@
1313
from fastmcp import Client
1414

1515

16-
# --- read_note: pagination params removed in #693 (were no-ops) ---
17-
# The `page` / `page_size` parameters were removed because the API endpoint
18-
# silently dropped them. Search-fallback pagination is unrelated to read_note.
16+
# --- read_note: pagination params restored in #883 ---
17+
# `page` / `page_size` were removed in #693 because they were no-ops on the
18+
# resource read. #883 restored them for parity with the sibling navigation
19+
# tools: they paginate the server-side search fallback (a direct match still
20+
# returns the full note content).
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_read_note_accepts_pagination_params_and_aliases(mcp_server, app, test_project):
25+
"""Agents passing page/page_size (or their aliases) must not get a validation error."""
26+
async with Client(mcp_server) as client:
27+
await client.call_tool(
28+
"write_note",
29+
{
30+
"project": test_project.name,
31+
"title": "Paged Read Note",
32+
"directory": "test",
33+
"content": "# Paged Read Note\n\npaged read body",
34+
},
35+
)
36+
37+
result = await client.call_tool(
38+
"read_note",
39+
{
40+
"project": test_project.name,
41+
"identifier": "Paged Read Note",
42+
"page": 1,
43+
"page_size": 10,
44+
},
45+
)
46+
assert "paged read body" in result.content[0].text
47+
48+
# Aliases map silently: page_number -> page, limit -> page_size
49+
result = await client.call_tool(
50+
"read_note",
51+
{
52+
"project": test_project.name,
53+
"identifier": "Paged Read Note",
54+
"page_number": 1,
55+
"limit": 10,
56+
},
57+
)
58+
assert "paged read body" in result.content[0].text
1959

2060

2161
# --- edit_note: find_text / content / section aliases ---
@@ -485,12 +525,12 @@ async def test_aliases_not_advertised_in_schema(mcp_server, app):
485525

486526
# tool_name -> (must_have_canonical, must_not_have_aliases)
487527
checks = {
488-
# read_note has no pagination params (#693 — they were no-ops; removed).
489-
# The must_not_have list still includes the rejected aliases so future
490-
# contributors don't reintroduce them.
528+
# read_note pagination restored in #883 (paginates the search fallback).
529+
# Accepted aliases (limit/page_number/per_page) plus the rejected
530+
# `offset` must stay out of the advertised schema.
491531
"read_note": (
492-
[],
493-
["page", "page_size", "offset", "limit", "page_number", "per_page"],
532+
["page", "page_size"],
533+
["offset", "limit", "page_number", "per_page"],
494534
),
495535
"edit_note": (
496536
["find_text", "section", "content"],

tests/mcp/test_tool_contracts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
"identifier",
6363
"project",
6464
"project_id",
65+
"page",
66+
"page_size",
6567
"output_format",
6668
"include_frontmatter",
6769
],

0 commit comments

Comments
 (0)