diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index e6a7f217..178fa325 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -1,13 +1,14 @@ """Read note tool for Basic Memory MCP server.""" from textwrap import dedent -from typing import Optional, Literal, cast +from typing import Annotated, Optional, Literal, cast import logfire import yaml from loguru import logger from fastmcp import Context +from pydantic import AliasChoices, Field from basic_memory.config import ConfigManager from basic_memory.mcp.project_context import ( @@ -20,6 +21,17 @@ from basic_memory.mcp.tools.search import search_notes 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).""" @@ -71,6 +83,21 @@ 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, @@ -99,6 +126,14 @@ async def read_note( 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 + 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 + always returns the full 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. + 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. output_format: "text" returns markdown content or guidance text. "json" returns a structured object with title/permalink/file_path/content/frontmatter. include_frontmatter: When output_format="json", whether content should include the @@ -122,6 +157,9 @@ async def read_note( # Read recent meeting notes read_note("team-docs", "Weekly Standup") + # 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 @@ -130,6 +168,15 @@ async def read_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}") + # 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: @@ -147,6 +194,8 @@ async def read_note( 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, ): @@ -245,7 +294,7 @@ async def read_note( ] async def _search_candidates( - identifier_text: str, *, title_only: bool + 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 @@ -256,11 +305,24 @@ async def read_note( # 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, ) @@ -297,12 +359,24 @@ async def read_note( logger.info(f"Direct lookup failed for '{entity_path}': {e}") # Continue to fallback methods - # Fallback 1: Try title search via API + # 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.info(f"Search title for: {identifier}") - title_results = await _search_candidates(identifier, title_only=True) - - title_candidates = _search_results(title_results) - if title_candidates: + 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. @@ -314,33 +388,37 @@ async def read_note( ), None, ) - if not result: + 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}") - elif _result_permalink(result): - try: - # Resolve the permalink to entity ID - entity_id = await knowledge_client.resolve_entity( - _result_permalink(result) or "", strict=True - ) + break - # Fetch content using the entity ID - response = await resource_client.read(entity_id) + if result is not None and _result_permalink(result): + try: + # Resolve the permalink to entity ID + entity_id = await knowledge_client.resolve_entity( + _result_permalink(result) or "", strict=True + ) - if response.status_code == 200: - logger.info( - f"Found note by exact title search: {_result_permalink(result)}" - ) - if output_format == "json": - return await _read_json_payload(entity_id) - return response.text - except Exception as e: # pragma: no cover + # Fetch content using the entity ID + response = await resource_client.read(entity_id) + + if response.status_code == 200: logger.info( - f"Failed to fetch content for found title match {_result_permalink(result)}: {e}" + f"Found note by exact title search: {_result_permalink(result)}" ) - else: - logger.info( - f"No results in title search for: {identifier} in project {active_project.name}" - ) + if output_format == "json": + return await _read_json_payload(entity_id) + return response.text + except Exception as e: # pragma: no cover + logger.info( + f"Failed to fetch content for found title match {_result_permalink(result)}: {e}" + ) # Fallback 2: Text search as a last resort logger.info(f"Title search failed, trying text search for: {identifier}") @@ -352,6 +430,9 @@ async def read_note( if output_format == "json": return _empty_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 = _empty_json_payload() payload["related_results"] = [ @@ -360,10 +441,10 @@ async def read_note( "permalink": _result_permalink(result), "file_path": _result_file_path(result), } - for result in text_candidates[:5] + for result in text_candidates ] return payload - return format_related_results(active_project.name, identifier, text_candidates[:5]) + return format_related_results(active_project.name, identifier, text_candidates) def format_not_found_message(project: str | None, identifier: str) -> str: diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index 0007e721..b423c6c7 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -87,8 +87,12 @@ async def write_note( Use forward slashes (/) as separators. Use "/" or "" to write to project root. Examples: "notes", "projects/2025", "research/ml", "/" (root) project: Project name to write to. Optional - server will resolve using the - hierarchy above. If unknown, use list_memory_projects() to discover - available projects. + hierarchy above. Use "workspace/project" to route to a project in a + specific cloud workspace. A bare name that exists in multiple + workspaces resolves to the default workspace, so use the qualified + form (or project_id) to disambiguate. If unknown, use + list_memory_projects() to discover available projects and their + qualified names. 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(). diff --git a/test-int/mcp/test_param_aliases_integration.py b/test-int/mcp/test_param_aliases_integration.py index cf08bb16..1c1c0674 100644 --- a/test-int/mcp/test_param_aliases_integration.py +++ b/test-int/mcp/test_param_aliases_integration.py @@ -13,9 +13,49 @@ import pytest from fastmcp import Client -# --- read_note: pagination params removed in #693 (were no-ops) --- -# The `page` / `page_size` parameters were removed because the API endpoint -# silently dropped them. Search-fallback pagination is unrelated to read_note. +# --- read_note: pagination params restored in #883 --- +# `page` / `page_size` were removed in #693 because they were no-ops on the +# resource read. #883 restored them for parity with the sibling navigation +# tools: they paginate the server-side search fallback (a direct match still +# returns the full note content). + + +@pytest.mark.asyncio +async def test_read_note_accepts_pagination_params_and_aliases(mcp_server, app, test_project): + """Agents passing page/page_size (or their aliases) must not get a validation error.""" + async with Client(mcp_server) as client: + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Paged Read Note", + "directory": "test", + "content": "# Paged Read Note\n\npaged read body", + }, + ) + + result = await client.call_tool( + "read_note", + { + "project": test_project.name, + "identifier": "Paged Read Note", + "page": 1, + "page_size": 10, + }, + ) + assert "paged read body" in result.content[0].text + + # Aliases map silently: page_number -> page, limit -> page_size + result = await client.call_tool( + "read_note", + { + "project": test_project.name, + "identifier": "Paged Read Note", + "page_number": 1, + "limit": 10, + }, + ) + assert "paged read body" in result.content[0].text # --- edit_note: find_text / content / section aliases --- @@ -485,12 +525,12 @@ async def test_aliases_not_advertised_in_schema(mcp_server, app): # tool_name -> (must_have_canonical, must_not_have_aliases) checks = { - # read_note has no pagination params (#693 — they were no-ops; removed). - # The must_not_have list still includes the rejected aliases so future - # contributors don't reintroduce them. + # read_note pagination restored in #883 (paginates the search fallback). + # Accepted aliases (limit/page_number/per_page) plus the rejected + # `offset` must stay out of the advertised schema. "read_note": ( - [], - ["page", "page_size", "offset", "limit", "page_number", "per_page"], + ["page", "page_size"], + ["offset", "limit", "page_number", "per_page"], ), "edit_note": ( ["find_text", "section", "content"], diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 4e1be403..82864838 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -62,6 +62,8 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = { "identifier", "project", "project_id", + "page", + "page_size", "output_format", "include_frontmatter", ], diff --git a/tests/mcp/test_tool_read_note.py b/tests/mcp/test_tool_read_note.py index dd2c6b69..9bf8a34d 100644 --- a/tests/mcp/test_tool_read_note.py +++ b/tests/mcp/test_tool_read_note.py @@ -120,6 +120,306 @@ async def test_read_note_returns_related_results_when_text_search_finds_matches( 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 write_note( + 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): + async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: + raise RuntimeError("force fallback") + + 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 write_note( + 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): + 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 RuntimeError("force direct lookup failure") + 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 write_note( + project=test_project.name, + title="Foo Bar Foo Bar", + directory="test", + content="fuzzy decoy content", + ) + await write_note( + 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): + 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 RuntimeError("force direct lookup failure") + 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_pages_past_higher_ranked_fuzzy_titles( + monkeypatch, app, test_project +): + """An exact title match is found even when it ranks beyond the first lookup page. + + bm25 ranks titles that repeat the queried phrase above the exact title, so with + more than _TITLE_LOOKUP_PAGE_SIZE such decoys the exact match lands on page 2 + of title results. A single-page lookup would fall through to suggestions even + though the note exists; the lookup must page until the exact title is found. + """ + 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 write_note( + 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 write_note( + project=test_project.name, + title="Deep Page Note", + directory="test", + content="deep page exact content", + ) + + # Precondition: the exact title must rank beyond the first lookup page, + # otherwise this test would pass even with a single-page lookup. + 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 "Deep Page Note" not in first_page_titles + 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): + 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 RuntimeError("force direct lookup failure") + 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): + async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: + raise RuntimeError("force fallback") + + 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): + async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: + raise RuntimeError("force fallback") + + 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.""" diff --git a/tests/mcp/test_tool_telemetry.py b/tests/mcp/test_tool_telemetry.py index a9a304f8..71cb0131 100644 --- a/tests/mcp/test_tool_telemetry.py +++ b/tests/mcp/test_tool_telemetry.py @@ -109,6 +109,8 @@ async def test_read_note_emits_root_operation_and_project_context( "tool_name": "read_note", "requested_project": test_project.name, "requested_project_id": None, + "page": 1, + "page_size": 10, "output_format": "json", "include_frontmatter": True, },