11"""Read note tool for Basic Memory MCP server."""
22
33from textwrap import dedent
4+ from typing import Optional
45
56from loguru import logger
67
78from basic_memory .mcp .async_client import client
89from basic_memory .mcp .server import mcp
910from basic_memory .mcp .tools .search import search_notes
1011from basic_memory .mcp .tools .utils import call_get
12+ from basic_memory .mcp .project_session import get_active_project
1113from basic_memory .schemas .memory import memory_url_path
14+ from basic_memory .utils import validate_project_path
1215
1316
1417@mcp .tool (
1518 description = "Read a markdown note by title or permalink." ,
1619)
17- async def read_note (identifier : str , page : int = 1 , page_size : int = 10 ) -> str :
20+ async def read_note (
21+ identifier : str , page : int = 1 , page_size : int = 10 , project : Optional [str ] = None
22+ ) -> str :
1823 """Read a markdown note from the knowledge base.
1924
2025 This tool finds and retrieves a note by its title, permalink, or content search,
@@ -26,6 +31,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
2631 Can be a full memory:// URL, a permalink, a title, or search text
2732 page: Page number for paginated results (default: 1)
2833 page_size: Number of items per page (default: 10)
34+ project: Optional project name to read from. If not provided, uses current active project.
2935
3036 Returns:
3137 The full markdown content of the note if found, or helpful guidance if not found.
@@ -42,10 +48,41 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
4248
4349 # Read with pagination
4450 read_note("Project Updates", page=2, page_size=5)
51+
52+ # Read from specific project
53+ read_note("Meeting Notes", project="work-project")
4554 """
55+
56+ # Get the active project first to check project-specific sync status
57+ active_project = get_active_project (project )
58+
59+ # Validate identifier to prevent path traversal attacks
60+ # We need to check both the raw identifier and the processed path
61+ processed_path = memory_url_path (identifier )
62+ project_path = active_project .home
63+
64+ if not validate_project_path (identifier , project_path ) or not validate_project_path (processed_path , project_path ):
65+ logger .warning (
66+ "Attempted path traversal attack blocked" ,
67+ identifier = identifier ,
68+ processed_path = processed_path ,
69+ project = active_project .name ,
70+ )
71+ return f"# Error\n \n Identifier '{ identifier } ' is not allowed - paths must stay within project boundaries"
72+
73+ # Check migration status and wait briefly if needed
74+ from basic_memory .mcp .tools .utils import wait_for_migration_or_return_status
75+
76+ migration_status = await wait_for_migration_or_return_status (
77+ timeout = 5.0 , project_name = active_project .name
78+ )
79+ if migration_status : # pragma: no cover
80+ return f"# System Status\n \n { migration_status } \n \n Please wait for migration to complete before reading notes."
81+ project_url = active_project .project_url
82+
4683 # Get the file via REST API - first try direct permalink lookup
4784 entity_path = memory_url_path (identifier )
48- path = f"/resource/{ entity_path } "
85+ path = f"{ project_url } /resource/{ entity_path } "
4986 logger .info (f"Attempting to read note from URL: { path } " )
5087
5188 try :
@@ -62,14 +99,14 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
6299
63100 # Fallback 1: Try title search via API
64101 logger .info (f"Search title for: { identifier } " )
65- title_results = await search_notes (query = identifier , search_type = "title" )
102+ title_results = await search_notes . fn (query = identifier , search_type = "title" , project = project )
66103
67104 if title_results and title_results .results :
68105 result = title_results .results [0 ] # Get the first/best match
69106 if result .permalink :
70107 try :
71108 # Try to fetch the content using the found permalink
72- path = f"/resource/{ result .permalink } "
109+ path = f"{ project_url } /resource/{ result .permalink } "
73110 response = await call_get (
74111 client , path , params = {"page" : page , "page_size" : page_size }
75112 )
@@ -86,7 +123,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
86123
87124 # Fallback 2: Text search as a last resort
88125 logger .info (f"Title search failed, trying text search for: { identifier } " )
89- text_results = await search_notes (query = identifier , search_type = "text" )
126+ text_results = await search_notes . fn (query = identifier , search_type = "text" , project = project )
90127
91128 # We didn't find a direct match, construct a helpful error message
92129 if not text_results or not text_results .results :
0 commit comments