fix(mcp): make multi-project search opt-in (#807)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-05-08 12:54:20 -05:00
committed by GitHub
parent 26381aeed1
commit 831dc1ecdc
12 changed files with 739 additions and 55 deletions
+5 -13
View File
@@ -439,19 +439,16 @@ def _canonical_memory_path_for_workspace(
workspace_type: str,
project_permalink: str,
remainder: str,
include_project: bool,
) -> str:
"""Return the stored canonical path for a workspace-qualified memory URL."""
normalized_remainder = remainder.strip("/")
if workspace_type == "organization":
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
elif workspace_type == "personal":
prefix = project_permalink if include_project else ""
else:
if workspace_type not in {"organization", "personal"}:
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
if not prefix:
return normalized_remainder
# Trigger: a caller supplied a workspace-qualified memory URL.
# Why: the first two path segments are the global route, even for Personal.
# Outcome: lookups preserve the complete workspace/project canonical permalink.
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
if not normalized_remainder:
return prefix
return f"{prefix}/{normalized_remainder}"
@@ -483,7 +480,6 @@ def _canonical_memory_path_for_active_route(
workspace_type=workspace_context.workspace_type,
project_permalink=active_project.permalink,
remainder=workspace_remainder,
include_project=include_project,
)
if cached_workspace is not None:
@@ -492,7 +488,6 @@ def _canonical_memory_path_for_active_route(
workspace_type=cached_workspace.workspace_type,
project_permalink=active_project.permalink,
remainder=workspace_remainder,
include_project=include_project,
)
if not include_project:
@@ -582,7 +577,6 @@ async def resolve_workspace_qualified_memory_url(
workspace_type=entry.workspace.workspace_type,
project_permalink=entry.project.permalink,
remainder=remainder,
include_project=ConfigManager().config.permalinks_include_project,
)
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
@@ -1130,7 +1124,6 @@ async def resolve_project_and_path(
workspace_type=cached_workspace.workspace_type,
project_permalink=cached_project.permalink,
remainder=remainder,
include_project=bool(include_project),
)
return cached_project, resolved_path, True
@@ -1153,7 +1146,6 @@ async def resolve_project_and_path(
workspace_type=workspace_context.workspace_type,
project_permalink=project_permalink,
remainder=remainder,
include_project=bool(include_project),
)
return active_project, resolved_path, True
+29 -18
View File
@@ -6,7 +6,7 @@ a list containing a single `{"type": "text", "text": "{...json...}"}` item.
"""
import json
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, cast
from fastmcp import Context
from loguru import logger
@@ -17,18 +17,30 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.search import SearchResponse, SearchResult
def _identifier_for_read_note(identifier: str) -> str:
"""Convert ChatGPT result ids into routable Basic Memory identifiers."""
stripped = identifier.strip()
if stripped.startswith("memory://") or "/" not in stripped:
return identifier
return f"memory://{stripped}"
def _format_search_results_for_chatgpt(
results: SearchResponse | list[SearchResult] | list[dict[str, Any]] | dict[str, Any],
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
) -> List[Dict[str, Any]]:
"""Format search results according to ChatGPT's expected schema.
Returns a list of result objects with id, title, and url fields.
"""
if isinstance(results, SearchResponse):
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
raw_results: list[SearchResult | dict[str, Any]] = list(results.results)
elif isinstance(results, dict):
nested_results = results.get("results")
raw_results = nested_results if isinstance(nested_results, list) else []
raw_results = (
cast(list[SearchResult | dict[str, Any]], nested_results)
if isinstance(nested_results, list)
else []
)
else:
raw_results = results
@@ -113,8 +125,7 @@ async def search(
logger.info(f"ChatGPT search request: query='{query}'")
try:
# Let search_notes resolve the default project via get_project_client(),
# which works in both local mode (ConfigManager) and cloud mode (database).
# Keep this adapter tiny: the real search behavior lives in search_notes.
results = await search_notes(
query=query,
page=1,
@@ -123,7 +134,6 @@ async def search(
context=context,
)
# Handle string error responses from search_notes
if isinstance(results, str):
logger.warning(f"Search failed with error: {results[:100]}...")
search_results = {
@@ -131,16 +141,17 @@ async def search(
"error": "Search failed",
"error_details": results[:500], # Truncate long error messages
}
else:
# Format successful results for ChatGPT
raw_results = results.get("results", []) if isinstance(results, dict) else []
formatted_results = _format_search_results_for_chatgpt(raw_results)
search_results = {
"results": formatted_results,
"total_count": len(raw_results), # Use actual count from results
"query": query,
}
logger.info(f"Search completed: {len(formatted_results)} results returned")
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
raw_results = results.get("results", []) if isinstance(results, dict) else []
formatted_results = _format_search_results_for_chatgpt(raw_results)
search_results = {
"results": formatted_results,
"total_count": len(raw_results), # Use actual count from results
"query": query,
}
logger.info(f"Search completed: {len(formatted_results)} results returned")
# Return in MCP content array format as required by OpenAI
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
@@ -180,7 +191,7 @@ async def fetch(
# which works in both local mode (ConfigManager) and cloud mode (database).
content = str(
await read_note(
identifier=id,
identifier=_identifier_for_read_note(id),
context=context,
)
)
+44 -16
View File
@@ -18,7 +18,8 @@ from basic_memory.mcp.project_context import (
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
from basic_memory.utils import generate_permalink, validate_project_path
from basic_memory.workspace_context import current_workspace_permalink_context
def _is_exact_title_match(identifier: str, title: str) -> bool:
@@ -277,24 +278,51 @@ async def read_note(
value = item.get("file_path")
return str(value) if value else None
try:
# Try to resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
def _legacy_workspace_unqualified_path(path: str) -> str | None:
workspace_context = current_workspace_permalink_context()
if workspace_context is None:
return None
# Fetch content using entity ID
response = await resource_client.read(entity_id)
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
project_prefix = active_project.permalink
qualified_prefix = f"{workspace_prefix}/{project_prefix}"
normalized_path = path.strip("/")
if normalized_path == qualified_prefix:
return project_prefix
if normalized_path.startswith(f"{qualified_prefix}/"):
return f"{project_prefix}/{normalized_path.removeprefix(f'{qualified_prefix}/')}"
return None
# If successful, return the content
if response.status_code == 200:
logger.info(
"Returning read_note result from resource: {path}", path=entity_path
direct_lookup_paths = [entity_path]
legacy_path = _legacy_workspace_unqualified_path(entity_path)
if legacy_path and legacy_path not in direct_lookup_paths:
# Trigger: existing cloud rows may still use project-prefixed permalinks.
# Why: new workspace-qualified IDs should read old notes without a re-sync.
# Outcome: try the legacy path after the canonical workspace path misses.
direct_lookup_paths.append(legacy_path)
for direct_lookup_path in direct_lookup_paths:
try:
# Try to resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(
direct_lookup_path, strict=True
)
if output_format == "json":
return await _read_json_payload(entity_id)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
# Continue to fallback methods
# Fetch content using entity ID
response = await resource_client.read(entity_id)
# If successful, return the content
if response.status_code == 200:
logger.info(
"Returning read_note result from resource: {path}",
path=direct_lookup_path,
)
if output_format == "json":
return await _read_json_payload(entity_id)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{direct_lookup_path}': {e}")
# Continue to alternate direct lookup paths, then fallback methods
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
+278 -1
View File
@@ -2,7 +2,8 @@
import re
from textwrap import dedent
from typing import Annotated, List, Optional, Dict, Any, Literal
from typing import Annotated, List, Optional, Dict, Any, Literal, cast
from uuid import UUID
import logfire
from loguru import logger
@@ -22,6 +23,7 @@ from basic_memory.schemas.search import (
SearchItemType,
SearchQuery,
SearchResponse,
SearchResult,
SearchRetrievalMode,
)
@@ -294,6 +296,247 @@ def _format_search_markdown(result: SearchResponse, project: str, query: str | N
return "\n".join(parts)
def _valid_project_id(value: object) -> str | None:
"""Return a UUID project id string when one is present."""
if not isinstance(value, str) or not value.strip():
return None
try:
return str(UUID(value))
except ValueError:
return None
def _matches_constrained_project(project: dict[str, Any], constrained_project: object) -> bool:
"""Return True when a project list row satisfies BASIC_MEMORY_MCP_PROJECT."""
if not isinstance(constrained_project, str) or not constrained_project.strip():
return True
candidates = {
value
for value in (
project.get("name"),
project.get("qualified_name"),
project.get("external_id"),
)
if isinstance(value, str)
}
return constrained_project in candidates
def _search_project_refs(projects_payload: object) -> list[dict[str, str | None]]:
"""Extract project routing refs for optional account-scoped search."""
if not isinstance(projects_payload, dict):
return []
payload = cast(dict[str, Any], projects_payload)
projects = payload.get("projects")
if not isinstance(projects, list):
return []
refs: list[dict[str, str | None]] = []
seen: set[tuple[str | None, str | None]] = set()
constrained_project = payload.get("constrained_project")
for item in projects:
if not isinstance(item, dict) or not _matches_constrained_project(
item, constrained_project
):
continue
project = item.get("qualified_name") or item.get("name")
project_name = project if isinstance(project, str) and project.strip() else None
project_id = _valid_project_id(item.get("external_id"))
if project_name is None and project_id is None:
continue
key = (project_name, project_id)
if key in seen:
continue
seen.add(key)
refs.append({"project": project_name, "project_id": project_id})
return refs
async def _load_search_project_refs(context: Context | None = None) -> list[dict[str, str | None]]:
"""Load accessible projects for search_all_projects without coupling the wrapper tool."""
from basic_memory.mcp.tools.project_management import list_memory_projects
return _search_project_refs(await list_memory_projects(output_format="json", context=context))
def _raw_results_from_search_payload(
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
) -> list[SearchResult | dict[str, Any]]:
"""Return the result list from any search_notes JSON-compatible payload."""
if isinstance(results, SearchResponse):
return list(results.results)
if isinstance(results, dict):
nested_results = results.get("results")
return (
cast(list[SearchResult | dict[str, Any]], nested_results)
if isinstance(nested_results, list)
else []
)
return list(results)
def _result_score(result: SearchResult | dict[str, Any]) -> float:
"""Return a comparable search score for merged project results."""
if isinstance(result, SearchResult):
return result.score
score = result.get("score")
return float(score) if isinstance(score, int | float) else 0.0
def _qualify_permalink_for_project(permalink: object, project: str | None) -> object:
"""Return a workspace-qualified permalink when the project ref supplies one."""
if not isinstance(permalink, str) or not permalink.strip():
return permalink
if not isinstance(project, str) or "/" not in project.strip("/"):
return permalink
normalized_permalink = permalink.strip("/")
qualified_project = project.strip("/")
if normalized_permalink == qualified_project or normalized_permalink.startswith(
f"{qualified_project}/"
):
return normalized_permalink
workspace_slug, project_permalink = qualified_project.split("/", 1)
if normalized_permalink == project_permalink or normalized_permalink.startswith(
f"{project_permalink}/"
):
return f"{workspace_slug}/{normalized_permalink}"
return f"{qualified_project}/{normalized_permalink}"
def _qualify_results_for_project(
results: list[SearchResult | dict[str, Any]],
project_ref: dict[str, str | None],
) -> list[dict[str, Any]]:
"""Attach the searched workspace/project prefix to each result permalink."""
qualified: list[dict[str, Any]] = []
for result in results:
if isinstance(result, SearchResult):
result_data = result.model_dump()
else:
result_data = dict(result)
result_data["permalink"] = _qualify_permalink_for_project(
result_data.get("permalink"),
project_ref.get("project"),
)
qualified.append(result_data)
return qualified
def _result_total(results: dict[str, Any], raw_results: list[SearchResult | dict[str, Any]]) -> int:
"""Return the best available total for a per-project search payload."""
total = results.get("total")
if isinstance(total, int) and total > 0:
return total
return len(raw_results) + (1 if results.get("has_more") is True else 0)
def _project_ref_label(project_ref: dict[str, str | None]) -> str:
"""Return a stable log label for a project search ref."""
return project_ref.get("project") or project_ref.get("project_id") or "<unknown project>"
async def _search_all_projects(
*,
query: str | None,
page: int,
page_size: int,
search_type: str | None,
output_format: Literal["text", "json"],
note_types: list[str],
entity_types: list[str],
after_date: str | None,
metadata_filters: dict[str, Any] | None,
tags: list[str] | None,
status: str | None,
min_similarity: float | None,
context: Context | None,
) -> dict | str:
"""Search every accessible project when the caller explicitly opts in."""
requested_page = max(page, 1)
requested_page_size = max(page_size, 1)
project_refs = await _load_search_project_refs(context=context)
if not project_refs:
response = SearchResponse(
results=[],
current_page=requested_page,
page_size=requested_page_size,
total=0,
has_more=False,
)
if output_format == "json":
return response.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(response, "all projects", query)
per_project_page_size = requested_page * requested_page_size
merged_results: list[dict[str, Any]] = []
total = 0
any_project_has_more = False
for project_ref in project_refs:
try:
results = await search_notes(
query=query,
project=project_ref["project"],
project_id=project_ref["project_id"],
page=1,
page_size=per_project_page_size,
search_type=search_type,
output_format="json",
note_types=note_types or None,
entity_types=entity_types or None,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
min_similarity=min_similarity,
search_all_projects=False,
context=context,
)
except Exception as exc:
logger.warning(
f"Multi-project search failed for project {_project_ref_label(project_ref)}: {exc}"
)
continue
if isinstance(results, str):
if not results.startswith("# Search Failed"):
return results
logger.warning(
"Multi-project search failed for project "
f"{_project_ref_label(project_ref)}: {results}"
)
continue
raw_results = _raw_results_from_search_payload(results)
total += _result_total(results, raw_results)
any_project_has_more = any_project_has_more or results.get("has_more") is True
merged_results.extend(_qualify_results_for_project(raw_results, project_ref))
sorted_results = sorted(merged_results, key=_result_score, reverse=True)
start = (requested_page - 1) * requested_page_size
end = start + requested_page_size
paged_results = sorted_results[start:end]
response = SearchResponse.model_validate(
{
"results": paged_results,
"current_page": requested_page,
"page_size": requested_page_size,
"total": total,
"has_more": any_project_has_more or total > end or len(sorted_results) > end,
}
)
if output_format == "json":
return response.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(response, "all projects", query)
@mcp.tool(
description="Search across all content in the knowledge base with advanced syntax support.",
# TODO: re-enable once MCP client rendering is working
@@ -309,6 +552,13 @@ async def search_notes(
] = None,
project: Optional[str] = None,
project_id: Optional[str] = None,
search_all_projects: Annotated[
bool,
Field(
default=False,
validation_alias=AliasChoices("search_all_projects", "all_projects"),
),
] = False,
# `offset` is intentionally NOT aliased to `page`: offset is item-indexed
# (skip N items) while page is 1-indexed page-number. Direct aliasing would
# silently return the wrong slice.
@@ -373,6 +623,8 @@ async def search_notes(
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.
Set search_all_projects=True to search every accessible project; this is opt-in because it
performs one search per project.
## Search Syntax Examples
@@ -448,6 +700,8 @@ async def search_notes(
project_id: Project external_id (UUID). Prefer this over `project` when known —
it routes to the exact project regardless of name collisions across cloud
workspaces. Takes precedence over `project`. Get from list_memory_projects().
search_all_projects: Optional opt-in to search every accessible project. Ignored when
`project` or `project_id` is supplied.
page: The page number of results to return (default 1)
page_size: The number of results to return per page (default 10)
search_type: Type of search to perform, one of:
@@ -562,12 +816,35 @@ async def search_notes(
if detected:
project = detected
# Trigger: caller explicitly requests account/workspace-wide search and did not
# already provide a concrete project route.
# Why: multi-project fan-out can be slow, so default search remains project-scoped.
# Outcome: run one normal search per accessible project and merge ranked results.
if search_all_projects and project is None and project_id is None:
all_projects_result = await _search_all_projects(
query=query,
page=page,
page_size=page_size,
search_type=search_type,
output_format=output_format,
note_types=note_types,
entity_types=entity_types,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
min_similarity=min_similarity,
context=context,
)
return all_projects_result
with logfire.span(
"mcp.tool.search_notes",
entrypoint="mcp",
tool_name="search_notes",
requested_project=project,
requested_project_id=project_id,
search_all_projects=search_all_projects,
search_type=search_type or "default",
output_format=output_format,
page=page,
+2 -2
View File
@@ -14,14 +14,14 @@ _WORKSPACE_TYPES = {"personal", "organization"}
@dataclass(frozen=True)
class WorkspacePermalinkContext:
"""Workspace metadata needed to build canonical organization permalinks."""
"""Workspace metadata needed to build canonical workspace permalinks."""
workspace_slug: str
workspace_type: str
@property
def should_prefix_permalinks(self) -> bool:
return self.workspace_type == "organization" and bool(self.workspace_slug)
return bool(self.workspace_slug)
_workspace_permalink_context: ContextVar[WorkspacePermalinkContext | None] = ContextVar(
+3 -3
View File
@@ -826,7 +826,7 @@ async def test_resolve_workspace_qualified_memory_url_uses_personal_canonical_pa
resolved = await resolve_workspace_qualified_memory_url("memory://personal/main/notes/foo")
assert resolved is not None
assert resolved.canonical_path == "main/notes/foo"
assert resolved.canonical_path == "personal/main/notes/foo"
@pytest.mark.asyncio
@@ -1373,7 +1373,7 @@ async def test_resolve_project_and_path_keeps_workspace_qualified_canonical_path
@pytest.mark.asyncio
async def test_resolve_project_and_path_strips_personal_workspace_prefix(
async def test_resolve_project_and_path_preserves_personal_workspace_prefix(
config_manager,
monkeypatch,
):
@@ -1417,7 +1417,7 @@ async def test_resolve_project_and_path_strips_personal_workspace_prefix(
)
assert active_project == cached_project
assert resolved_path == "main/notes/foo"
assert resolved_path == "personal/main/notes/foo"
assert is_memory_url is True
+1
View File
@@ -83,6 +83,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"query",
"project",
"project_id",
"search_all_projects",
"page",
"page_size",
"search_type",
+64 -2
View File
@@ -380,14 +380,14 @@ async def test_team_workspace_write_stores_complete_permalink_when_project_prefi
@pytest.mark.asyncio
async def test_personal_workspace_write_keeps_project_scoped_permalink(
async def test_personal_workspace_write_stores_complete_canonical_permalink(
app,
test_project,
entity_repository,
):
from basic_memory.workspace_context import workspace_permalink_context
expected_permalink = f"{test_project.name}/personal/personal-workspace-note"
expected_permalink = f"personal/{test_project.name}/personal/personal-workspace-note"
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
write_result = await write_note(
@@ -404,6 +404,68 @@ async def test_personal_workspace_write_keeps_project_scoped_permalink(
assert stored.permalink == expected_permalink
@pytest.mark.asyncio
async def test_read_note_personal_workspace_keeps_short_project_permalink_working(
app,
test_project,
):
from basic_memory.workspace_context import workspace_permalink_context
legacy_permalink = f"{test_project.name}/personal/short-personal-note"
await write_note(
project=test_project.name,
title="Short Personal Note",
directory="personal",
content="Short personal workspace content",
)
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
read_result = await read_note(
f"memory://{legacy_permalink}",
project=test_project.name,
output_format="json",
)
assert isinstance(read_result, dict)
assert read_result["permalink"] == legacy_permalink
assert read_result["content"].strip() == "Short personal workspace content"
@pytest.mark.asyncio
async def test_read_note_workspace_qualified_personal_url_finds_legacy_short_permalink(
app,
test_project,
entity_repository,
):
from basic_memory.workspace_context import workspace_permalink_context
legacy_permalink = f"{test_project.name}/personal/legacy-personal-note"
qualified_permalink = f"personal/{legacy_permalink}"
await write_note(
project=test_project.name,
title="Legacy Personal Note",
directory="personal",
content="Legacy personal workspace content",
)
stored = await entity_repository.get_by_permalink(legacy_permalink)
assert stored is not None
assert stored.permalink == legacy_permalink
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
read_result = await read_note(
f"memory://{qualified_permalink}",
project=test_project.name,
output_format="json",
)
assert isinstance(read_result, dict)
assert read_result["permalink"] == legacy_permalink
assert read_result["content"].strip() == "Legacy personal workspace content"
@pytest.mark.asyncio
async def test_read_note_workspace_qualified_memory_url_uses_complete_permalink(
app,
+1
View File
@@ -157,6 +157,7 @@ async def test_search_notes_emits_root_operation_and_project_context(
"tool_name": "search_notes",
"requested_project": test_project.name,
"requested_project_id": None,
"search_all_projects": False,
"search_type": "text",
"output_format": "json",
"page": 1,
+50
View File
@@ -85,6 +85,35 @@ async def test_search_uses_dynamic_default_search_type(monkeypatch, client, test
assert "search_type" not in captured_kwargs
@pytest.mark.asyncio
async def test_search_delegates_to_search_notes_without_project_iteration(
monkeypatch, client, test_project
):
"""ChatGPT search is only a compatibility wrapper around search_notes."""
import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools
captured_kwargs: dict = {}
async def fake_search_notes_fn(*args, **kwargs):
captured_kwargs.update(kwargs)
return {"results": []}
monkeypatch.setattr(chatgpt_tools, "search_notes", fake_search_notes_fn)
result = await chatgpt_tools.search("MCP Test Note")
content = json.loads(result[0]["text"])
assert content["results"] == []
assert content["query"] == "MCP Test Note"
assert captured_kwargs == {
"query": "MCP Test Note",
"page": 1,
"page_size": 10,
"output_format": "json",
"context": None,
}
@pytest.mark.asyncio
async def test_fetch_successful_document(client, test_project):
"""Test fetch with successful document retrieval."""
@@ -127,6 +156,27 @@ async def test_fetch_document_not_found(client, test_project):
assert content["metadata"]["error"] == "Document not found"
@pytest.mark.asyncio
async def test_fetch_routes_path_ids_as_memory_urls(monkeypatch, client, test_project):
"""Workspace-qualified search ids need memory URL routing during fetch."""
import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools
captured: dict[str, str] = {}
async def fake_read_note(*, identifier: str, context=None):
captured["identifier"] = identifier
return "# MCP Test Note\n\nFetched from the requested workspace."
monkeypatch.setattr(chatgpt_tools, "read_note", fake_read_note)
result = await chatgpt_tools.fetch("team-paul/main/tests/mcp-test-note")
content = json.loads(result[0]["text"])
assert captured["identifier"] == "memory://team-paul/main/tests/mcp-test-note"
assert content["id"] == "team-paul/main/tests/mcp-test-note"
assert content["title"] == "MCP Test Note"
def test_format_search_results_for_chatgpt():
"""Test search results formatting."""
from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt
@@ -0,0 +1,256 @@
"""Tests for optional multi-project search_notes behavior."""
from contextlib import asynccontextmanager
import importlib
import pytest
from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult
@pytest.mark.asyncio
async def test_search_notes_search_all_projects_qualifies_result_permalinks(monkeypatch):
"""Multi-project search belongs to search_notes and keeps result ids routable."""
clients_mod = importlib.import_module("basic_memory.mcp.clients")
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
project_refs = [
{
"project": "personal/main",
"project_id": "11111111-1111-1111-1111-111111111111",
},
{
"project": "team-paul/main",
"project_id": "22222222-2222-2222-2222-222222222222",
},
]
searched_projects: list[tuple[str | None, str | None]] = []
async def fake_load_search_project_refs(context=None):
return project_refs
class StubProject:
def __init__(self, name: str | None, external_id: str | None):
self.name = name or "main"
self.external_id = external_id or "local-main"
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
searched_projects.append((project, project_id))
yield object(), StubProject(project, project_id)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
return StubProject(project, None), identifier, False
class MockSearchClient:
def __init__(self, client, project_id):
self.project_id = project_id
async def search(self, payload, page, page_size):
if self.project_id == "11111111-1111-1111-1111-111111111111":
title = "Personal MCP Test Note"
score = 0.5
else:
title = "Team MCP Test Note"
score = 0.9
return SearchResponse(
results=[
SearchResult(
title=title,
permalink="main/tests/mcp-test-note",
content="MCP content",
type=SearchItemType.ENTITY,
score=score,
file_path="/main/tests/mcp-test-note.md",
)
],
current_page=page,
page_size=page_size,
total=1,
)
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
query="MCP Test Note",
search_all_projects=True,
output_format="json",
)
assert isinstance(result, dict)
assert searched_projects == [
("personal/main", "11111111-1111-1111-1111-111111111111"),
("team-paul/main", "22222222-2222-2222-2222-222222222222"),
]
assert [item["permalink"] for item in result["results"]] == [
"team-paul/main/tests/mcp-test-note",
"personal/main/tests/mcp-test-note",
]
@pytest.mark.asyncio
async def test_search_notes_multi_project_search_is_opt_in(monkeypatch):
"""Default search_notes calls stay scoped to the resolved project."""
clients_mod = importlib.import_module("basic_memory.mcp.clients")
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
searched_projects: list[tuple[str | None, str | None]] = []
async def fake_load_search_project_refs(context=None):
raise AssertionError("project discovery should only run when search_all_projects=True")
class StubProject:
name = "main"
external_id = "local-main"
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
searched_projects.append((project, project_id))
yield object(), StubProject()
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
return StubProject(), identifier, False
class MockSearchClient:
def __init__(self, *args, **kwargs):
pass
async def search(self, payload, page, page_size):
return SearchResponse(results=[], current_page=page, page_size=page_size)
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(query="MCP Test Note", output_format="json")
assert isinstance(result, dict)
assert result["results"] == []
assert searched_projects == [(None, None)]
@pytest.mark.asyncio
async def test_search_notes_search_all_projects_with_no_refs_returns_empty_all_projects(
monkeypatch,
):
"""Explicit all-project search must not silently fall back to one project."""
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
async def fake_load_search_project_refs(context=None):
return []
@asynccontextmanager
async def fail_get_project_client(*args, **kwargs):
raise AssertionError("search_all_projects=True should not fall back to scoped search")
yield
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
monkeypatch.setattr(search_mod, "get_project_client", fail_get_project_client)
result = await search_mod.search_notes(
query="MCP Test Note",
search_all_projects=True,
output_format="json",
)
assert isinstance(result, dict)
assert result == {
"results": [],
"current_page": 1,
"page_size": 10,
"total": 0,
"has_more": False,
}
@pytest.mark.asyncio
async def test_search_notes_search_all_projects_continues_after_project_failure(
monkeypatch,
):
"""One failing project should not discard successful all-project search results."""
clients_mod = importlib.import_module("basic_memory.mcp.clients")
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
project_refs = [
{
"project": "personal/main",
"project_id": "11111111-1111-1111-1111-111111111111",
},
{
"project": "team-paul/main",
"project_id": "22222222-2222-2222-2222-222222222222",
},
]
warnings: list[str] = []
async def fake_load_search_project_refs(context=None):
return project_refs
class StubProject:
def __init__(self, name: str | None, external_id: str | None):
self.name = name or "main"
self.external_id = external_id or "local-main"
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
yield object(), StubProject(project, project_id)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
return StubProject(project, None), identifier, False
class FakeLogger:
def debug(self, *args, **kwargs):
pass
def error(self, *args, **kwargs):
pass
def warning(self, message, *args, **kwargs):
warnings.append(str(message))
class MockSearchClient:
def __init__(self, client, project_id):
self.project_id = project_id
async def search(self, payload, page, page_size):
if self.project_id == "22222222-2222-2222-2222-222222222222":
raise RuntimeError("team index unavailable")
return SearchResponse(
results=[
SearchResult(
title="Personal MCP Test Note",
permalink="main/tests/mcp-test-note",
content="MCP content",
type=SearchItemType.ENTITY,
score=0.5,
file_path="/main/tests/mcp-test-note.md",
)
],
current_page=page,
page_size=page_size,
total=1,
)
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(search_mod, "logger", FakeLogger())
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
query="MCP Test Note",
search_all_projects=True,
output_format="json",
)
assert isinstance(result, dict)
assert [item["permalink"] for item in result["results"]] == [
"personal/main/tests/mcp-test-note",
]
assert result["total"] == 1
assert any("team-paul/main" in warning for warning in warnings)
assert any("team index unavailable" in warning for warning in warnings)
+6
View File
@@ -8,6 +8,7 @@ from basic_memory.workspace_context import (
current_workspace_permalink_context,
workspace_permalink_context,
workspace_permalink_headers,
workspace_slug_for_canonical_permalinks,
)
@@ -42,3 +43,8 @@ def test_workspace_permalink_headers_reflect_active_context():
}
assert current_workspace_permalink_context() is None
def test_personal_workspace_slug_is_canonical_permalink_prefix():
with workspace_permalink_context("personal", "personal"):
assert workspace_slug_for_canonical_permalinks() == "personal"