-
Notifications
You must be signed in to change notification settings - Fork 188
Expand file tree
/
Copy pathread_note.py
More file actions
261 lines (210 loc) · 9.23 KB
/
read_note.py
File metadata and controls
261 lines (210 loc) · 9.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@mcp.tool(
description="Read a markdown note by title or permalink.",
)
async def read_note(
identifier: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
context: Context | None = None,
) -> str:
"""Return the raw markdown for a note, or guidance text if no match is found.
Finds and retrieves a note by its title, permalink, or content search,
returning the raw markdown content including observations, relations, and metadata.
Project Resolution:
Server resolves projects in this order: Single Project Mode → project parameter → default project.
If project unknown, use list_memory_projects() or recent_activity() first.
This tool will try multiple lookup strategies to find the most relevant note:
1. Direct permalink lookup
2. Title search fallback
3. Text search as last resort
Args:
project: Project name to read from. Optional - server will resolve using the
hierarchy above. If unknown, use list_memory_projects() to discover
available 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 number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
context: Optional FastMCP context for performance caching.
Returns:
The full markdown content of the note if found, or helpful guidance if not found.
Content includes frontmatter, observations, relations, and all markdown formatting.
Examples:
# Read by permalink
read_note("my-research", "specs/search-spec")
# Read by title
read_note("work-project", "Search Specification")
# Read with memory URL
read_note("my-research", "memory://specs/search-spec")
# Read with pagination
read_note("work-project", "Project Updates", page=2, page_size=5)
# Read recent meeting notes
read_note("team-docs", "Weekly Standup")
Raises:
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If identifier attempts path traversal
Note:
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
# Get and validate the project
active_project = await get_active_project(client, project, context)
# Validate identifier to prevent path traversal attacks
# We need to check both the raw identifier and the processed path
processed_path = memory_url_path(identifier)
project_path = active_project.home
if not validate_project_path(identifier, project_path) or not validate_project_path(
processed_path, project_path
):
logger.warning(
"Attempted path traversal attack blocked",
identifier=identifier,
processed_path=processed_path,
project=active_project.name,
)
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
project_url = active_project.project_url
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier)
path = f"{project_url}/resource/{entity_path}"
logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}")
try:
# Try direct lookup first
response = await call_get(client, path, params={"page": page, "page_size": page_size})
# If successful, return the content
if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{path}': {e}")
# Continue to fallback methods
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes.fn(
query=identifier, search_type="title", project=project, context=context
)
if title_results and title_results.results:
result = title_results.results[0] # Get the first/best match
if result.permalink:
try:
# Try to fetch the content using the found permalink
path = f"{project_url}/resource/{result.permalink}"
response = await call_get(
client, path, params={"page": page, "page_size": page_size}
)
if response.status_code == 200:
logger.info(f"Found note by title search: {result.permalink}")
return response.text
except Exception as e: # pragma: no cover
logger.info(
f"Failed to fetch content for found title match {result.permalink}: {e}"
)
else:
logger.info(
f"No results in title search for: {identifier} in project {active_project.name}"
)
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes.fn(
query=identifier, search_type="text", project=project, context=context
)
# We didn't find a direct match, construct a helpful error message
if not text_results or not text_results.results:
# No results at all
return format_not_found_message(active_project.name, identifier)
else:
# We found some related results
return format_related_results(active_project.name, identifier, text_results.results[:5])
def format_not_found_message(project: str | None, identifier: str) -> str:
"""Format a helpful message when no note was found."""
return dedent(f"""
# Note Not Found in {project}: "{identifier}"
I couldn't find any notes matching "{identifier}". Here are some suggestions:
## Check Identifier Type
- If you provided a title, try using the exact permalink instead
- If you provided a permalink, check for typos or try a broader search
## Search Instead
Try searching for related content:
```
search_notes(project="{project}", query="{identifier}")
```
## Recent Activity
Check recently modified notes:
```
recent_activity(timeframe="7d")
```
## Create New Note
This might be a good opportunity to create a new note on this topic:
```
write_note(
project="{project}",
title="{identifier.capitalize()}",
content='''
# {identifier.capitalize()}
## Overview
[Your content here]
## Observations
- [category] [Observation about {identifier}]
## Relations
- relates_to [[Related Topic]]
''',
folder="notes"
)
```
""")
def format_related_results(project: str | None, identifier: str, results) -> str:
"""Format a helpful message with related results when an exact match wasn't found."""
message = dedent(f"""
# Note Not Found in {project}: "{identifier}"
I couldn't find an exact match for "{identifier}", but I found some related notes:
""")
for i, result in enumerate(results):
message += dedent(f"""
## {i + 1}. {result.title}
- **Type**: {result.type.value}
- **Permalink**: {result.permalink}
You can read this note with:
```
read_note(project="{project}", {result.permalink}")
```
""")
message += dedent(f"""
## Try More Specific Lookup
For exact matches, try using the full permalink from one of the results above.
## Search For More Results
To see more related content:
```
search_notes(project="{project}", query="{identifier}")
```
## Create New Note
If none of these match what you're looking for, consider creating a new note:
```
write_note(
project="{project}",
title="[Your title]",
content="[Your content]",
folder="notes"
)
```
""")
return message