Skip to content

Commit 12b5152

Browse files
phernandezclaude
andauthored
fix: implement project-specific sync status checks for MCP tools (#183)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac9e148 commit 12b5152

File tree

8 files changed

+190
-48
lines changed

8 files changed

+190
-48
lines changed

src/basic_memory/mcp/tools/build_context.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,15 @@ async def build_context(
8282
logger.info(f"Building context from {url}")
8383
# URL is already validated and normalized by MemoryUrl type annotation
8484

85+
# Get the active project first to check project-specific sync status
86+
active_project = get_active_project(project)
87+
8588
# Check migration status and wait briefly if needed
8689
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
8790

88-
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
91+
migration_status = await wait_for_migration_or_return_status(
92+
timeout=5.0, project_name=active_project.name
93+
)
8994
if migration_status: # pragma: no cover
9095
# Return a proper GraphContext with status message
9196
from basic_memory.schemas.memory import MemoryMetadata
@@ -102,8 +107,6 @@ async def build_context(
102107
uri=migration_status, # Include status in metadata
103108
),
104109
)
105-
106-
active_project = get_active_project(project)
107110
project_url = active_project.project_url
108111

109112
response = await call_get(

src/basic_memory/mcp/tools/read_note.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,17 @@ async def read_note(
5252
read_note("Meeting Notes", project="work-project")
5353
"""
5454

55+
# Get the active project first to check project-specific sync status
56+
active_project = get_active_project(project)
57+
5558
# Check migration status and wait briefly if needed
5659
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
5760

58-
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
61+
migration_status = await wait_for_migration_or_return_status(
62+
timeout=5.0, project_name=active_project.name
63+
)
5964
if migration_status: # pragma: no cover
6065
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
61-
62-
active_project = get_active_project(project)
6366
project_url = active_project.project_url
6467

6568
# Get the file via REST API - first try direct permalink lookup

src/basic_memory/mcp/tools/utils.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -525,11 +525,16 @@ def check_migration_status() -> Optional[str]:
525525
return None
526526

527527

528-
async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[str]:
528+
async def wait_for_migration_or_return_status(
529+
timeout: float = 5.0, project_name: Optional[str] = None
530+
) -> Optional[str]:
529531
"""Wait briefly for sync/migration to complete, or return status message.
530532
531533
Args:
532534
timeout: Maximum time to wait for sync completion
535+
project_name: Optional project name to check specific project status.
536+
If provided, only checks that project's readiness.
537+
If None, uses global status check (legacy behavior).
533538
534539
Returns:
535540
Status message if sync is still in progress, None if ready
@@ -538,18 +543,36 @@ async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[
538543
from basic_memory.services.sync_status_service import sync_status_tracker
539544
import asyncio
540545

541-
if sync_status_tracker.is_ready:
546+
# Check if we should use project-specific or global status
547+
def is_ready() -> bool:
548+
if project_name:
549+
return sync_status_tracker.is_project_ready(project_name)
550+
return sync_status_tracker.is_ready
551+
552+
if is_ready():
542553
return None
543554

544555
# Wait briefly for sync to complete
545556
start_time = asyncio.get_event_loop().time()
546557
while (asyncio.get_event_loop().time() - start_time) < timeout:
547-
if sync_status_tracker.is_ready:
558+
if is_ready():
548559
return None
549560
await asyncio.sleep(0.1) # Check every 100ms
550561

551562
# Still not ready after timeout
552-
return sync_status_tracker.get_summary()
563+
if project_name:
564+
# For project-specific checks, get project status details
565+
project_status = sync_status_tracker.get_project_status(project_name)
566+
if project_status and project_status.status.value == "failed":
567+
error_msg = project_status.error or "Unknown sync error"
568+
return f"❌ Sync failed for project '{project_name}': {error_msg}"
569+
elif project_status:
570+
return f"🔄 Project '{project_name}' is still syncing: {project_status.message}"
571+
else:
572+
return f"⚠️ Project '{project_name}' status unknown"
573+
else:
574+
# Fall back to global summary for legacy calls
575+
return sync_status_tracker.get_summary()
553576
except Exception: # pragma: no cover
554577
# If there's any error, assume ready
555578
return None

src/basic_memory/mcp/tools/write_note.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,15 @@ async def write_note(
7272
"""
7373
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
7474

75+
# Get the active project first to check project-specific sync status
76+
active_project = get_active_project(project)
77+
7578
# Check migration status and wait briefly if needed
7679
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
7780

78-
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
81+
migration_status = await wait_for_migration_or_return_status(
82+
timeout=5.0, project_name=active_project.name
83+
)
7984
if migration_status: # pragma: no cover
8085
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
8186

@@ -91,7 +96,6 @@ async def write_note(
9196
content=content,
9297
entity_metadata=metadata,
9398
)
94-
active_project = get_active_project(project)
9599
project_url = active_project.project_url
96100

97101
# Create or update via knowledge API

src/basic_memory/repository/search_repository.py

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -123,64 +123,64 @@ async def init_search_index(self):
123123

124124
def _prepare_boolean_query(self, query: str) -> str:
125125
"""Prepare a Boolean query by quoting individual terms while preserving operators.
126-
126+
127127
Args:
128128
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
129-
129+
130130
Returns:
131131
A properly formatted Boolean query with quoted terms that need quoting
132132
"""
133133
# Define Boolean operators and their boundaries
134-
boolean_pattern = r'(\bAND\b|\bOR\b|\bNOT\b)'
135-
134+
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
135+
136136
# Split the query by Boolean operators, keeping the operators
137137
parts = re.split(boolean_pattern, query)
138-
138+
139139
processed_parts = []
140140
for part in parts:
141141
part = part.strip()
142142
if not part:
143143
continue
144-
144+
145145
# If it's a Boolean operator, keep it as is
146-
if part in ['AND', 'OR', 'NOT']:
146+
if part in ["AND", "OR", "NOT"]:
147147
processed_parts.append(part)
148148
else:
149149
# Handle parentheses specially - they should be preserved for grouping
150-
if '(' in part or ')' in part:
150+
if "(" in part or ")" in part:
151151
# Parse parenthetical expressions carefully
152152
processed_part = self._prepare_parenthetical_term(part)
153153
processed_parts.append(processed_part)
154154
else:
155155
# This is a search term - for Boolean queries, don't add prefix wildcards
156156
prepared_term = self._prepare_single_term(part, is_prefix=False)
157157
processed_parts.append(prepared_term)
158-
158+
159159
return " ".join(processed_parts)
160-
160+
161161
def _prepare_parenthetical_term(self, term: str) -> str:
162162
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
163-
163+
164164
Args:
165165
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
166-
166+
167167
Returns:
168168
A properly formatted term with parentheses preserved
169169
"""
170170
# Handle terms that start/end with parentheses but may contain quotable content
171171
result = ""
172172
i = 0
173173
while i < len(term):
174-
if term[i] in '()':
174+
if term[i] in "()":
175175
# Preserve parentheses as-is
176176
result += term[i]
177177
i += 1
178178
else:
179179
# Find the next parenthesis or end of string
180180
start = i
181-
while i < len(term) and term[i] not in '()':
181+
while i < len(term) and term[i] not in "()":
182182
i += 1
183-
183+
184184
# Extract the content between parentheses
185185
content = term[start:i].strip()
186186
if content:
@@ -191,43 +191,71 @@ def _prepare_parenthetical_term(self, term: str) -> str:
191191
result += f'"{escaped_content}"'
192192
else:
193193
result += content
194-
194+
195195
return result
196-
196+
197197
def _needs_quoting(self, term: str) -> bool:
198198
"""Check if a term needs to be quoted for FTS5 safety.
199-
199+
200200
Args:
201201
term: The term to check
202-
202+
203203
Returns:
204204
True if the term should be quoted
205205
"""
206206
if not term or not term.strip():
207207
return False
208-
208+
209209
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
210-
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-", "'", '"',
211-
"[", "]", "{", "}", "+", "!", "@", "#", "$", "%", "^", "&",
212-
"=", "|", "\\", "~", "`"]
213-
210+
needs_quoting_chars = [
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+
214242
return any(c in term for c in needs_quoting_chars)
215-
243+
216244
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
217245
"""Prepare a single search term (no Boolean operators).
218-
246+
219247
Args:
220248
term: A single search term
221249
is_prefix: Whether to add prefix search capability (* suffix)
222-
250+
223251
Returns:
224252
A properly formatted single term
225253
"""
226254
if not term or not term.strip():
227255
return term
228-
256+
229257
term = term.strip()
230-
258+
231259
# Check if term is already a proper wildcard pattern (alphanumeric + *)
232260
# e.g., "hello*", "test*world" - these should be left alone
233261
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):

src/basic_memory/services/sync_status_service.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ def is_ready(self) -> bool: # pragma: no cover
131131
"""Check if system is ready (no sync in progress)."""
132132
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
133133

134+
def is_project_ready(self, project_name: str) -> bool:
135+
"""Check if a specific project is ready for operations.
136+
137+
Args:
138+
project_name: Name of the project to check
139+
140+
Returns:
141+
True if the project is ready (completed, watching, or not tracked),
142+
False if the project is syncing, scanning, or failed
143+
"""
144+
project_status = self._project_statuses.get(project_name)
145+
if not project_status:
146+
# Project not tracked = ready (likely hasn't been synced yet)
147+
return True
148+
149+
return project_status.status in (SyncStatus.COMPLETED, SyncStatus.WATCHING, SyncStatus.IDLE)
150+
134151
def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
135152
"""Get status for a specific project."""
136153
return self._project_statuses.get(project_name)

tests/repository/test_search_repository.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -334,15 +334,30 @@ def test_hyphenated_terms_with_boolean_operators(self, search_repository):
334334
# Test the specific case from the GitHub issue
335335
result = search_repository._prepare_search_term("tier1-test AND unicode")
336336
assert result == '"tier1-test" AND unicode'
337-
337+
338338
# Test other hyphenated Boolean combinations
339-
assert search_repository._prepare_search_term("multi-word OR single") == '"multi-word" OR single'
340-
assert search_repository._prepare_search_term("well-formed NOT badly-formed") == '"well-formed" NOT "badly-formed"'
341-
assert search_repository._prepare_search_term("test-case AND (hello OR world)") == '"test-case" AND (hello OR world)'
342-
339+
assert (
340+
search_repository._prepare_search_term("multi-word OR single")
341+
== '"multi-word" OR single'
342+
)
343+
assert (
344+
search_repository._prepare_search_term("well-formed NOT badly-formed")
345+
== '"well-formed" NOT "badly-formed"'
346+
)
347+
assert (
348+
search_repository._prepare_search_term("test-case AND (hello OR world)")
349+
== '"test-case" AND (hello OR world)'
350+
)
351+
343352
# Test mixed special characters with Boolean operators
344-
assert search_repository._prepare_search_term("config.json AND test-file") == '"config.json" AND "test-file"'
345-
assert search_repository._prepare_search_term("C++ OR python-script") == '"C++" OR "python-script"'
353+
assert (
354+
search_repository._prepare_search_term("config.json AND test-file")
355+
== '"config.json" AND "test-file"'
356+
)
357+
assert (
358+
search_repository._prepare_search_term("C++ OR python-script")
359+
== '"C++" OR "python-script"'
360+
)
346361

347362
def test_programming_terms_should_work(self, search_repository):
348363
"""Programming-related terms with special chars should be searchable."""

0 commit comments

Comments
 (0)