fix(mcp): validate navigation pagination and fix recent_activity project display (#915)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-06-07 23:03:00 -05:00
committed by GitHub
parent 8570d96bad
commit 7c0937f658
4 changed files with 277 additions and 5 deletions
@@ -209,6 +209,18 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
# Validate pagination arguments before they reach the context service.
# Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative).
# Why: a non-positive page_size flows into context_service as limit, where the
# primary slice does primary = primary[:limit] — so limit=0 truncates the
# requested entity to [] and the caller's valid memory:// lookup silently
# returns primary_count=0. Mirrors recent_activity's guard for consistency.
# Outcome: caller gets an explicit ValueError instead of a dropped primary result.
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 memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
+11 -5
View File
@@ -300,14 +300,20 @@ async def recent_activity(
else:
# Project-Specific Mode: Get activity for specific project
# Uses get_project_client() for per-project routing (local vs cloud)
logger.info(
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
)
async with get_project_client(resolved_project, context=context, project_id=project_id) as (
client,
active_project,
):
# Trigger: caller routed by project_id (a UUID), so resolved_project holds the
# raw UUID rather than a human-readable name.
# Why: active_project.name is the canonical, display-safe project name regardless
# of whether routing was by name or by external_id.
# Outcome: logs and the formatted text header always show the project name.
logger.info(
f"Getting recent activity from project {active_project.name}: "
f"type={type}, depth={depth}, timeframe={timeframe}"
)
response = await call_get(
client,
f"/v2/projects/{active_project.external_id}/memory/recent",
@@ -319,7 +325,7 @@ async def recent_activity(
return _extract_recent_rows(activity_data)
# Format project-specific mode output
return _format_project_output(resolved_project, activity_data, timeframe, type, page)
return _format_project_output(active_project.name, activity_data, timeframe, type, page)
async def _get_project_activity(
+13
View File
@@ -866,6 +866,19 @@ async def search_notes(
# Explicit project specification
results = await search_notes("project planning", project="my-project")
"""
# Validate pagination arguments before they reach the API/repository layer.
# Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or a negative slice).
# Why: a non-positive page_size yields zero rows yet the router computes
# has_more = offset + len(results) < total, returning a misleading
# has_more=True with no reachable page; a negative page_size becomes an
# uncapped SQLite LIMIT. Mirrors recent_activity's guard so all navigation
# tools reject invalid pagination consistently.
# Outcome: caller gets an explicit ValueError instead of a silent bad payload.
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}")
# Avoid mutable-default-argument footguns. Treat None as "no filter".
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []