Skip to content

Commit 831dc1e

Browse files
authored
fix(mcp): make multi-project search opt-in (#807)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 26381ae commit 831dc1e

12 files changed

Lines changed: 740 additions & 56 deletions

src/basic_memory/mcp/project_context.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -439,19 +439,16 @@ def _canonical_memory_path_for_workspace(
439439
workspace_type: str,
440440
project_permalink: str,
441441
remainder: str,
442-
include_project: bool,
443442
) -> str:
444443
"""Return the stored canonical path for a workspace-qualified memory URL."""
445444
normalized_remainder = remainder.strip("/")
446-
if workspace_type == "organization":
447-
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
448-
elif workspace_type == "personal":
449-
prefix = project_permalink if include_project else ""
450-
else:
445+
if workspace_type not in {"organization", "personal"}:
451446
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
452447

453-
if not prefix:
454-
return normalized_remainder
448+
# Trigger: a caller supplied a workspace-qualified memory URL.
449+
# Why: the first two path segments are the global route, even for Personal.
450+
# Outcome: lookups preserve the complete workspace/project canonical permalink.
451+
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
455452
if not normalized_remainder:
456453
return prefix
457454
return f"{prefix}/{normalized_remainder}"
@@ -483,7 +480,6 @@ def _canonical_memory_path_for_active_route(
483480
workspace_type=workspace_context.workspace_type,
484481
project_permalink=active_project.permalink,
485482
remainder=workspace_remainder,
486-
include_project=include_project,
487483
)
488484

489485
if cached_workspace is not None:
@@ -492,7 +488,6 @@ def _canonical_memory_path_for_active_route(
492488
workspace_type=cached_workspace.workspace_type,
493489
project_permalink=active_project.permalink,
494490
remainder=workspace_remainder,
495-
include_project=include_project,
496491
)
497492

498493
if not include_project:
@@ -582,7 +577,6 @@ async def resolve_workspace_qualified_memory_url(
582577
workspace_type=entry.workspace.workspace_type,
583578
project_permalink=entry.project.permalink,
584579
remainder=remainder,
585-
include_project=ConfigManager().config.permalinks_include_project,
586580
)
587581
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
588582

@@ -1130,7 +1124,6 @@ async def resolve_project_and_path(
11301124
workspace_type=cached_workspace.workspace_type,
11311125
project_permalink=cached_project.permalink,
11321126
remainder=remainder,
1133-
include_project=bool(include_project),
11341127
)
11351128
return cached_project, resolved_path, True
11361129

@@ -1153,7 +1146,6 @@ async def resolve_project_and_path(
11531146
workspace_type=workspace_context.workspace_type,
11541147
project_permalink=project_permalink,
11551148
remainder=remainder,
1156-
include_project=bool(include_project),
11571149
)
11581150
return active_project, resolved_path, True
11591151

src/basic_memory/mcp/tools/chatgpt_tools.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
import json
9-
from typing import Any, Dict, List, Optional
9+
from typing import Any, Dict, List, Optional, cast
1010

1111
from fastmcp import Context
1212
from loguru import logger
@@ -17,18 +17,30 @@
1717
from basic_memory.schemas.search import SearchResponse, SearchResult
1818

1919

20+
def _identifier_for_read_note(identifier: str) -> str:
21+
"""Convert ChatGPT result ids into routable Basic Memory identifiers."""
22+
stripped = identifier.strip()
23+
if stripped.startswith("memory://") or "/" not in stripped:
24+
return identifier
25+
return f"memory://{stripped}"
26+
27+
2028
def _format_search_results_for_chatgpt(
21-
results: SearchResponse | list[SearchResult] | list[dict[str, Any]] | dict[str, Any],
29+
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
2230
) -> List[Dict[str, Any]]:
2331
"""Format search results according to ChatGPT's expected schema.
2432
2533
Returns a list of result objects with id, title, and url fields.
2634
"""
2735
if isinstance(results, SearchResponse):
28-
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
36+
raw_results: list[SearchResult | dict[str, Any]] = list(results.results)
2937
elif isinstance(results, dict):
3038
nested_results = results.get("results")
31-
raw_results = nested_results if isinstance(nested_results, list) else []
39+
raw_results = (
40+
cast(list[SearchResult | dict[str, Any]], nested_results)
41+
if isinstance(nested_results, list)
42+
else []
43+
)
3244
else:
3345
raw_results = results
3446

@@ -113,8 +125,7 @@ async def search(
113125
logger.info(f"ChatGPT search request: query='{query}'")
114126

115127
try:
116-
# Let search_notes resolve the default project via get_project_client(),
117-
# which works in both local mode (ConfigManager) and cloud mode (database).
128+
# Keep this adapter tiny: the real search behavior lives in search_notes.
118129
results = await search_notes(
119130
query=query,
120131
page=1,
@@ -123,24 +134,24 @@ async def search(
123134
context=context,
124135
)
125136

126-
# Handle string error responses from search_notes
127137
if isinstance(results, str):
128138
logger.warning(f"Search failed with error: {results[:100]}...")
129139
search_results = {
130140
"results": [],
131141
"error": "Search failed",
132142
"error_details": results[:500], # Truncate long error messages
133143
}
134-
else:
135-
# Format successful results for ChatGPT
136-
raw_results = results.get("results", []) if isinstance(results, dict) else []
137-
formatted_results = _format_search_results_for_chatgpt(raw_results)
138-
search_results = {
139-
"results": formatted_results,
140-
"total_count": len(raw_results), # Use actual count from results
141-
"query": query,
142-
}
143-
logger.info(f"Search completed: {len(formatted_results)} results returned")
144+
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
145+
146+
raw_results = results.get("results", []) if isinstance(results, dict) else []
147+
148+
formatted_results = _format_search_results_for_chatgpt(raw_results)
149+
search_results = {
150+
"results": formatted_results,
151+
"total_count": len(raw_results), # Use actual count from results
152+
"query": query,
153+
}
154+
logger.info(f"Search completed: {len(formatted_results)} results returned")
144155

145156
# Return in MCP content array format as required by OpenAI
146157
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
@@ -180,7 +191,7 @@ async def fetch(
180191
# which works in both local mode (ConfigManager) and cloud mode (database).
181192
content = str(
182193
await read_note(
183-
identifier=id,
194+
identifier=_identifier_for_read_note(id),
184195
context=context,
185196
)
186197
)

src/basic_memory/mcp/tools/read_note.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from basic_memory.mcp.server import mcp
1919
from basic_memory.mcp.tools.search import search_notes
2020
from basic_memory.schemas.memory import memory_url_path
21-
from basic_memory.utils import validate_project_path
21+
from basic_memory.utils import generate_permalink, validate_project_path
22+
from basic_memory.workspace_context import current_workspace_permalink_context
2223

2324

2425
def _is_exact_title_match(identifier: str, title: str) -> bool:
@@ -277,24 +278,51 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
277278
value = item.get("file_path")
278279
return str(value) if value else None
279280

280-
try:
281-
# Try to resolve identifier to entity ID
282-
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
281+
def _legacy_workspace_unqualified_path(path: str) -> str | None:
282+
workspace_context = current_workspace_permalink_context()
283+
if workspace_context is None:
284+
return None
285+
286+
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
287+
project_prefix = active_project.permalink
288+
qualified_prefix = f"{workspace_prefix}/{project_prefix}"
289+
normalized_path = path.strip("/")
290+
if normalized_path == qualified_prefix:
291+
return project_prefix
292+
if normalized_path.startswith(f"{qualified_prefix}/"):
293+
return f"{project_prefix}/{normalized_path.removeprefix(f'{qualified_prefix}/')}"
294+
return None
295+
296+
direct_lookup_paths = [entity_path]
297+
legacy_path = _legacy_workspace_unqualified_path(entity_path)
298+
if legacy_path and legacy_path not in direct_lookup_paths:
299+
# Trigger: existing cloud rows may still use project-prefixed permalinks.
300+
# Why: new workspace-qualified IDs should read old notes without a re-sync.
301+
# Outcome: try the legacy path after the canonical workspace path misses.
302+
direct_lookup_paths.append(legacy_path)
303+
304+
for direct_lookup_path in direct_lookup_paths:
305+
try:
306+
# Try to resolve identifier to entity ID
307+
entity_id = await knowledge_client.resolve_entity(
308+
direct_lookup_path, strict=True
309+
)
283310

284-
# Fetch content using entity ID
285-
response = await resource_client.read(entity_id)
311+
# Fetch content using entity ID
312+
response = await resource_client.read(entity_id)
286313

287-
# If successful, return the content
288-
if response.status_code == 200:
289-
logger.info(
290-
"Returning read_note result from resource: {path}", path=entity_path
291-
)
292-
if output_format == "json":
293-
return await _read_json_payload(entity_id)
294-
return response.text
295-
except Exception as e: # pragma: no cover
296-
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
297-
# Continue to fallback methods
314+
# If successful, return the content
315+
if response.status_code == 200:
316+
logger.info(
317+
"Returning read_note result from resource: {path}",
318+
path=direct_lookup_path,
319+
)
320+
if output_format == "json":
321+
return await _read_json_payload(entity_id)
322+
return response.text
323+
except Exception as e: # pragma: no cover
324+
logger.info(f"Direct lookup failed for '{direct_lookup_path}': {e}")
325+
# Continue to alternate direct lookup paths, then fallback methods
298326

299327
# Fallback 1: Try title search via API
300328
logger.info(f"Search title for: {identifier}")

0 commit comments

Comments
 (0)