feat(mcp): discover projects across workspaces (#757)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-04-18 14:04:57 -05:00
committed by GitHub
parent 56d6f1b4a5
commit f3e46d7984
13 changed files with 1504 additions and 280 deletions
+471 -79
View File
@@ -8,8 +8,10 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple
from dataclasses import dataclass
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
from httpx import AsyncClient
from httpx._types import (
@@ -20,7 +22,7 @@ from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
import logfire
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
from basic_memory.project_resolver import ProjectResolver
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
from basic_memory.schemas.project_info import ProjectItem, ProjectList
@@ -33,6 +35,29 @@ from basic_memory.utils import generate_permalink, normalize_project_reference
# The cloud MCP server sets a provider that queries its own database directly,
# avoiding the control-plane HTTP round-trip that requires local credentials.
_workspace_provider: Optional[Callable[[], Awaitable[list[WorkspaceInfo]]]] = None
_WORKSPACE_PROJECT_INDEX_STATE_KEY = "workspace_project_index"
@dataclass(frozen=True)
class WorkspaceProjectEntry:
"""A cloud project resolved together with the workspace that owns it."""
workspace: WorkspaceInfo
project: ProjectItem
@property
def qualified_name(self) -> str:
return f"{self.workspace.slug}/{self.project.permalink}"
@dataclass(frozen=True)
class WorkspaceProjectIndex:
"""Session-local cloud project lookup index keyed by project permalink."""
workspaces: tuple[WorkspaceInfo, ...]
entries: tuple[WorkspaceProjectEntry, ...]
entries_by_permalink: dict[str, tuple[WorkspaceProjectEntry, ...]]
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
@@ -88,6 +113,37 @@ async def _set_cached_active_project(
await context.set_state("default_project_name", active_project.name)
async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
"""Return the cached active workspace from context when available."""
if not context:
return None
cached_raw = await context.get_state("active_workspace")
if isinstance(cached_raw, dict):
return WorkspaceInfo.model_validate(cached_raw)
return None
async def _set_cached_active_workspace(
context: Optional[Context],
active_workspace: WorkspaceInfo,
) -> None:
"""Persist workspace context and clear project cache when the tenant changes."""
if not context:
return
cached_workspace = await _get_cached_active_workspace(context)
if cached_workspace and cached_workspace.tenant_id != active_workspace.tenant_id:
# Trigger: project routing moved to another workspace
# Why: project names are only unique inside one workspace, so a cached
# ProjectItem from the previous tenant can point at the wrong project
# Outcome: force the next validation call to resolve within the new tenant
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
await context.set_state("active_workspace", active_workspace.model_dump())
async def _get_cached_default_project(context: Optional[Context]) -> Optional[str]:
"""Return the cached default project name from context when available."""
if not context:
@@ -211,9 +267,11 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
def _workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
"""Return True when identifier matches workspace tenant_id or name."""
"""Return True when identifier matches workspace tenant_id, slug, or name."""
if workspace.tenant_id == identifier:
return True
if workspace.slug.casefold() == identifier.casefold():
return True
return workspace.name.lower() == identifier.lower()
@@ -223,13 +281,115 @@ def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
[
(
f"- {item.name} "
f"(type={item.workspace_type}, role={item.role}, tenant_id={item.tenant_id})"
f"(slug={item.slug}, type={item.workspace_type}, "
f"role={item.role}, tenant_id={item.tenant_id})"
)
for item in workspaces
]
)
def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex | None:
"""Deserialize a cached workspace project index from MCP context state."""
if not isinstance(raw, dict):
return None
raw_mapping = cast(dict[str, object], raw)
workspaces_raw = raw_mapping.get("workspaces")
entries_raw = raw_mapping.get("entries")
if not isinstance(workspaces_raw, list) or not isinstance(entries_raw, list):
return None
workspaces = tuple(WorkspaceInfo.model_validate(item) for item in workspaces_raw)
failed_workspaces_raw = raw_mapping.get("failed_workspaces")
failed_workspaces = (
tuple(WorkspaceInfo.model_validate(item) for item in failed_workspaces_raw)
if isinstance(failed_workspaces_raw, list)
else ()
)
entries_list: list[WorkspaceProjectEntry] = []
for item in entries_raw:
if not isinstance(item, dict):
continue
item_mapping = cast(dict[str, object], item)
workspace_raw = item_mapping.get("workspace")
project_raw = item_mapping.get("project")
if workspace_raw is None or project_raw is None:
continue
entries_list.append(
WorkspaceProjectEntry(
workspace=WorkspaceInfo.model_validate(workspace_raw),
project=ProjectItem.model_validate(project_raw),
)
)
entries = tuple(entries_list)
return _build_workspace_project_index(
workspaces,
entries,
failed_workspaces=failed_workspaces,
)
def _workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict:
"""Serialize a workspace project index for MCP context state."""
return {
"workspaces": [workspace.model_dump() for workspace in index.workspaces],
"failed_workspaces": [workspace.model_dump() for workspace in index.failed_workspaces],
"entries": [
{
"workspace": entry.workspace.model_dump(),
"project": entry.project.model_dump(),
}
for entry in index.entries
],
}
def _build_workspace_project_index(
workspaces: tuple[WorkspaceInfo, ...],
entries: tuple[WorkspaceProjectEntry, ...],
*,
failed_workspaces: tuple[WorkspaceInfo, ...] = (),
) -> WorkspaceProjectIndex:
"""Build the permalink lookup table for workspace-project entries."""
grouped: dict[str, list[WorkspaceProjectEntry]] = {}
for entry in entries:
grouped.setdefault(entry.project.permalink, []).append(entry)
return WorkspaceProjectIndex(
workspaces=workspaces,
entries=entries,
entries_by_permalink={
permalink: tuple(items)
for permalink, items in sorted(grouped.items(), key=lambda item: item[0])
},
failed_workspaces=failed_workspaces,
)
def _split_qualified_project_identifier(identifier: str) -> tuple[str | None, str]:
"""Split ``<workspace-slug>/<project>`` identifiers for cloud routing."""
cleaned = identifier.strip()
if "/" not in cleaned:
return None, cleaned
workspace_slug, project_identifier = cleaned.split("/", 1)
if not workspace_slug or not project_identifier:
return None, cleaned
return workspace_slug, project_identifier
def _unqualified_project_identifier(identifier: str) -> str:
"""Return the project segment from an optional qualified project identifier."""
_, project_identifier = _split_qualified_project_identifier(identifier)
return project_identifier
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
"""Format qualified project choices for collision errors."""
return " or ".join(entry.qualified_name for entry in entries)
async def get_available_workspaces(context: Optional[Context] = None) -> list[WorkspaceInfo]:
"""Load available cloud workspaces for the current authenticated user."""
if context:
@@ -266,6 +426,238 @@ async def get_available_workspaces(context: Optional[Context] = None) -> list[Wo
return workspace_list.workspaces
async def invalidate_workspace_project_index(context: Optional[Context] = None) -> None:
"""Invalidate the cached cloud workspace/project lookup index."""
if context:
await context.set_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY, None)
async def _fetch_workspace_project_entries(
workspace: WorkspaceInfo,
context: Optional[Context] = None,
) -> tuple[WorkspaceProjectEntry, ...]:
"""Fetch projects for one workspace and tag each project with workspace metadata."""
from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode
from basic_memory.mcp.clients import ProjectClient
client_context = (
get_client(workspace=workspace.tenant_id)
if is_factory_mode()
else get_cloud_proxy_client(workspace=workspace.tenant_id)
)
async with client_context as client:
project_list = await ProjectClient(client).list_projects()
default_permalink = (
generate_permalink(project_list.default_project) if project_list.default_project else None
)
entries: list[WorkspaceProjectEntry] = []
for project in project_list.projects:
entry_project = project
if default_permalink and project.permalink == default_permalink and not project.is_default:
entry_project = project.model_copy(update={"is_default": True})
entries.append(WorkspaceProjectEntry(workspace=workspace, project=entry_project))
if context: # pragma: no cover
await context.info(
f"Discovered {len(entries)} cloud projects in workspace {workspace.slug}"
)
return tuple(entries)
async def _ensure_workspace_project_index(
context: Optional[Context] = None,
) -> WorkspaceProjectIndex:
"""Build or load the session-local workspace/project lookup index."""
if context:
cached_raw = await context.get_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY)
cached_index = _workspace_project_index_from_state(cached_raw)
if cached_index is not None:
return cached_index
workspaces = tuple(await get_available_workspaces(context=context))
if not workspaces:
raise ValueError(
"No accessible workspaces found for this account. "
"Ensure you have an active subscription and tenant access."
)
fetched_results = await asyncio.gather(
*[_fetch_workspace_project_entries(workspace, context=context) for workspace in workspaces],
return_exceptions=True,
)
entries_list: list[WorkspaceProjectEntry] = []
failed_workspaces: list[WorkspaceInfo] = []
successful_fetches = 0
for workspace, result in zip(workspaces, fetched_results, strict=True):
if isinstance(result, BaseException):
if not isinstance(result, Exception):
raise result
# Trigger: one workspace project listing failed during a multi-workspace index.
# Why: a transient or unauthorized tenant should not break qualified routing for
# healthy workspaces, but unqualified routing still needs to know the index is partial.
# Outcome: keep successful workspace entries and record the failed workspace.
failed_workspaces.append(workspace)
logger.warning(
f"Cloud project discovery failed for workspace {workspace.slug} "
f"({workspace.tenant_id}): {result}"
)
if context: # pragma: no cover
await context.info(
f"Cloud project discovery failed for workspace {workspace.slug}; "
"continuing with other workspaces"
)
continue
workspace_entries = cast(tuple[WorkspaceProjectEntry, ...], result)
successful_fetches += 1
entries_list.extend(workspace_entries)
if failed_workspaces and successful_fetches == 0:
failed_labels = ", ".join(workspace.slug for workspace in failed_workspaces)
raise ValueError(
"Unable to discover projects in any accessible workspace. "
f"Failed workspaces: {failed_labels}"
)
entries = tuple(entries_list)
index = _build_workspace_project_index(
workspaces,
entries,
failed_workspaces=tuple(failed_workspaces),
)
if context:
await context.set_state(
_WORKSPACE_PROJECT_INDEX_STATE_KEY,
_workspace_project_index_to_state(index),
)
return index
async def ensure_workspace_project_index(
context: Optional[Context] = None,
) -> WorkspaceProjectIndex:
"""Public wrapper for loading the session-local workspace/project lookup index."""
return await _ensure_workspace_project_index(context=context)
async def resolve_workspace_project_identifier(
project: str,
context: Optional[Context] = None,
) -> WorkspaceProjectEntry:
"""Resolve an unqualified or ``<workspace>/<project>`` cloud project identifier."""
index = await _ensure_workspace_project_index(context=context)
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
project_permalink = generate_permalink(project_identifier)
if workspace_slug:
workspace_matches = [
workspace
for workspace in index.workspaces
if workspace.slug.casefold() == workspace_slug.casefold()
]
if not workspace_matches:
available = ", ".join(workspace.slug for workspace in index.workspaces)
raise ValueError(
f"Workspace '{workspace_slug}' was not found. "
f"Available workspace slugs: {available}"
)
workspace = workspace_matches[0]
matches = [
entry
for entry in index.entries_by_permalink.get(project_permalink, ())
if entry.workspace.tenant_id == workspace.tenant_id
]
if not matches:
if any(
failed_workspace.tenant_id == workspace.tenant_id
for failed_workspace in index.failed_workspaces
):
raise ValueError(
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
"could not be loaded. Retry after workspace discovery recovers."
)
available = ", ".join(
entry.qualified_name
for entry in index.entries
if entry.workspace.tenant_id == workspace.tenant_id
)
raise ValueError(
f"Project '{project_identifier}' was not found in workspace "
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
)
return matches[0]
matches = index.entries_by_permalink.get(project_permalink, ())
if not matches:
failed_note = ""
if index.failed_workspaces:
failed = ", ".join(workspace.slug for workspace in index.failed_workspaces)
failed_note = (
f" Project discovery failed for workspace(s): {failed}; "
"retry or use a qualified project from an indexed workspace."
)
available = ", ".join(entry.qualified_name for entry in index.entries)
raise ValueError(
f"Project '{project}' was not found in indexed cloud workspaces. "
f"Available projects: {available}.{failed_note}"
)
cached_workspace = await _get_cached_active_workspace(context)
if cached_workspace:
cached_matches = [
entry for entry in matches if entry.workspace.tenant_id == cached_workspace.tenant_id
]
if cached_matches:
return cached_matches[0]
if len(matches) > 1:
choices = _format_qualified_choices(matches)
details = "\n".join(
f"- {entry.workspace.name} ({entry.workspace.slug}): {entry.qualified_name}"
for entry in matches
)
raise ValueError(
f"Project '{project}' exists in multiple workspaces. Use: {choices}\n{details}"
)
if index.failed_workspaces:
qualified_name = matches[0].qualified_name
failed = ", ".join(workspace.slug for workspace in index.failed_workspaces)
raise ValueError(
f"Project '{project}' was found as {qualified_name}, but project discovery "
f"failed for workspace(s): {failed}. Use '{qualified_name}' to route "
"explicitly, or retry after discovery recovers."
)
return matches[0]
async def _default_workspace_project_entry(
context: Optional[Context] = None,
) -> WorkspaceProjectEntry | None:
"""Return the default project from the default cloud workspace, when available."""
index = await _ensure_workspace_project_index(context=context)
default_workspace = next(
(workspace for workspace in index.workspaces if workspace.is_default),
None,
)
if default_workspace is None:
return None
default_entries = [
entry
for entry in index.entries
if entry.workspace.tenant_id == default_workspace.tenant_id and entry.project.is_default
]
return default_entries[0] if default_entries else None
async def resolve_workspace_parameter(
workspace: Optional[str] = None,
context: Optional[Context] = None,
@@ -320,8 +712,8 @@ async def resolve_workspace_parameter(
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
await _set_cached_active_workspace(context, selected_workspace)
if context:
await context.set_state("active_workspace", selected_workspace.model_dump())
logger.debug(f"Cached workspace in context: {selected_workspace.tenant_id}")
return selected_workspace
@@ -575,18 +967,16 @@ async def get_project_client(
network), creates the correctly-routed client, then validates via API.
Routing decision order:
1. Explicit --local/--cloud flags → skip workspace, use flag routing
2. Cloud routing (explicit --cloud OR project mode CLOUD) →
resolve workspace via priority chain, create cloud client
3. Otherwise → local ASGI client
1. Explicit --local flag → skip workspace, use local routing
2. Factory/cloud routing → resolve project through workspace/project index
3. Cloud project mode → resolve project through workspace/project index
4. Otherwise → local ASGI client
Workspace resolution priority (when cloud routing):
1. Explicit ``workspace`` parameter
2. Per-project ``workspace_id`` from config
3. Global ``default_workspace`` from config
4. MCP session cache (context)
5. Auto-select if single workspace
6. Error listing choices
3. Qualified project identifier (``<workspace-slug>/<project>``)
4. Workspace/project index lookup with collision detection
Args:
project: Optional explicit project parameter
@@ -610,6 +1000,25 @@ async def get_project_client(
# Step 1: Resolve project name from config (no network call)
resolved_project = await resolve_project_parameter(project, context=context)
config = ConfigManager().config
factory_mode = is_factory_mode()
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
cloud_default_entry: WorkspaceProjectEntry | None = None
if (
resolved_project is None
and not (_explicit_routing() and _force_local_mode())
and (
factory_mode
or explicit_cloud_routing
or (not config.projects and has_cloud_credentials(config))
)
):
cloud_default_entry = await _default_workspace_project_entry(context=context)
if cloud_default_entry is not None:
resolved_project = cloud_default_entry.project.name
await _set_cached_active_workspace(context, cloud_default_entry.workspace)
if not resolved_project:
# Fall back to local client to discover projects and raise helpful error
async with get_client() as client:
@@ -620,26 +1029,6 @@ async def get_project_client(
f"Available projects: {project_names}"
)
# Step 1b: Factory injection (in-process cloud server)
# Trigger: set_client_factory() was called (e.g., by cloud MCP server)
# Why: the factory's transport layer handles auth and tenant resolution;
# we pass workspace through so the transport can route to the correct
# workspace when the tool specifies one different from the connection default
# Outcome: factory client with optional workspace override via inner request headers
if is_factory_mode():
route_mode = "factory"
with logfire.span(
"routing.client_session",
project_name=resolved_project,
route_mode=route_mode,
workspace_id=workspace,
):
logger.debug("Using injected client factory for project routing")
async with get_client(workspace=workspace) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
return
# Step 2: Check explicit routing BEFORE workspace resolution
# Trigger: CLI passed --local or --cloud
# Why: explicit flags must be deterministic — skip workspace entirely for --local
@@ -658,66 +1047,69 @@ async def get_project_client(
return
# Step 3: Determine if cloud routing is needed
config = ConfigManager().config
project_entry = config.projects.get(resolved_project)
project_mode = config.get_project_mode(resolved_project)
# Trigger: workspace provided for a local project (without explicit --cloud)
# Why: workspace selection is a cloud routing concern only
# Outcome: fail fast with a deterministic guidance message
if project_mode != ProjectMode.CLOUD and workspace is not None and not _explicit_routing():
if (
not factory_mode
and project_mode != ProjectMode.CLOUD
and workspace is not None
and not _explicit_routing()
):
raise ValueError(
f"Workspace '{workspace}' cannot be used with local project '{resolved_project}'. "
"Workspace selection is only supported for cloud-mode projects."
)
if project_mode == ProjectMode.CLOUD or (_explicit_routing() and not _force_local_mode()):
# --- Cloud routing: resolve workspace with priority chain ---
effective_workspace = workspace
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
route_mode = "factory" if factory_mode else "cloud_proxy"
active_ws: WorkspaceInfo | None = None
workspace_id: str
project_for_api = _unqualified_project_identifier(resolved_project)
# Priority 2: per-project workspace_id from config
if effective_workspace is None and project_entry and project_entry.workspace_id:
effective_workspace = project_entry.workspace_id
# Priority 3: global default_workspace from config
if effective_workspace is None and config.default_workspace:
effective_workspace = config.default_workspace
route_mode = "cloud_proxy"
# Priorities 4-6: if still unresolved, fall back to resolve_workspace_parameter
# which checks context cache, auto-selects single workspace, or errors
if effective_workspace is not None:
# Config-resolved workspace — pass directly to get_client, skip network lookup
with logfire.span(
"routing.client_session",
project_name=resolved_project,
route_mode=route_mode,
workspace_id=effective_workspace,
):
logger.debug("Using configured workspace for cloud project routing")
async with get_client(
project_name=resolved_project,
workspace=effective_workspace,
) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
# Trigger: a script or config entry pins the tenant explicitly
# Why: explicit tenant configuration remains the escape hatch during migration
# Outcome: route to that workspace, but validate the project name inside it
if workspace is not None:
active_ws = await resolve_workspace_parameter(workspace=workspace, context=context)
workspace_id = active_ws.tenant_id
elif project_entry and project_entry.workspace_id:
# Trigger: the local project config already stores the cloud tenant id.
# Why: routing can send that id directly; requiring workspace discovery here
# would turn a control-plane listing outage into a project routing failure.
# Outcome: preserve project-scoped routing even when discovery is unavailable.
workspace_id = project_entry.workspace_id
else:
# No config-based workspace — use resolve_workspace_parameter for discovery
active_ws = await resolve_workspace_parameter(workspace=None, context=context)
with logfire.span(
"routing.client_session",
project_name=resolved_project,
route_mode=route_mode,
workspace_id=active_ws.tenant_id,
resolved_entry = cloud_default_entry
if resolved_entry is None or not _project_matches_identifier(
resolved_entry.project, resolved_project
):
logger.debug("Resolved workspace dynamically for cloud project routing")
async with get_client(
project_name=resolved_project,
workspace=active_ws.tenant_id,
) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
resolved_entry = await resolve_workspace_project_identifier(
resolved_project,
context=context,
)
active_ws = resolved_entry.workspace
workspace_id = active_ws.tenant_id
project_for_api = resolved_entry.project.name
if active_ws is not None:
await _set_cached_active_workspace(context, active_ws)
with logfire.span(
"routing.client_session",
project_name=project_for_api,
route_mode=route_mode,
workspace_id=workspace_id,
):
logger.debug("Using resolved workspace for cloud project routing")
async with get_client(
project_name=project_for_api,
workspace=workspace_id,
) as client:
active_project = await get_active_project(client, project_for_api, context)
yield client, active_project
return
# Step 4: Local routing (default)
+175 -29
View File
@@ -12,6 +12,11 @@ from loguru import logger
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
ensure_workspace_project_index,
resolve_workspace_parameter,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList
from basic_memory.utils import generate_permalink
@@ -26,7 +31,8 @@ async def _fetch_cloud_projects(
) -> ProjectList | None:
"""Fetch projects from the cloud API, returning None on failure.
Logs warnings on failure so the caller can fall back to local-only results.
Logs warnings on failure so list_memory_projects can fall back to local-only
results. Project-scoped routing does not use this listing fallback.
"""
try:
from basic_memory.mcp.clients import ProjectClient
@@ -38,9 +44,14 @@ async def _fetch_cloud_projects(
await context.info(f"Discovered {len(cloud_list.projects)} cloud projects")
return cloud_list
except Exception as exc:
logger.warning(f"Cloud project discovery failed: {exc}")
logger.warning(
f"Cloud project discovery failed while listing projects; "
f"showing local-only project list: {exc}"
)
if context: # pragma: no cover
await context.info("Cloud project discovery failed, showing local projects only")
await context.info(
"Cloud project discovery failed while listing projects; showing local projects only"
)
return None
@@ -51,6 +62,8 @@ def _merge_projects(
cloud_workspace_name: str | None = None,
cloud_workspace_type: str | None = None,
cloud_workspace_tenant_id: str | None = None,
cloud_workspace_slug: str | None = None,
cloud_workspace_is_default: bool = False,
) -> list[dict]:
"""Merge local and cloud project lists by permalink.
@@ -126,21 +139,118 @@ def _merge_projects(
"workspace_name": ws_name,
"workspace_type": ws_type,
"workspace_tenant_id": ws_tenant_id,
"workspace_slug": cloud_workspace_slug if cloud_proj else None,
"workspace_is_default": cloud_workspace_is_default if cloud_proj else False,
"qualified_name": (
f"{cloud_workspace_slug}/{permalink}"
if cloud_proj and cloud_workspace_slug
else None
),
}
)
return merged
def _merge_workspace_projects(
local_list: ProjectList | None,
cloud_entries: tuple[WorkspaceProjectEntry, ...],
) -> list[dict]:
"""Merge local projects with cloud projects from every accessible workspace."""
local_by_permalink: dict[str, ProjectItem] = {}
if local_list:
for project in local_list.projects:
local_by_permalink[project.permalink] = project
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
merged: list[dict] = []
for entry in sorted(
cloud_entries,
key=lambda item: (
not item.workspace.is_default,
item.workspace.workspace_type != "personal",
item.workspace.name.casefold(),
item.project.permalink,
),
):
permalink = entry.project.permalink
local_proj = local_by_permalink.get(permalink)
cloud_proj = entry.project
source = "local+cloud" if local_proj else "cloud"
local_path = local_proj.path if local_proj else None
cloud_path = cloud_proj.path
merged.append(
{
"name": cloud_proj.name,
"path": local_path or cloud_path,
"local_path": local_path,
"cloud_path": cloud_path,
"source": source,
"is_default": bool((local_proj and local_proj.is_default) or cloud_proj.is_default),
"is_private": cloud_proj.is_private,
"display_name": cloud_proj.display_name,
"workspace_name": entry.workspace.name,
"workspace_type": entry.workspace.workspace_type,
"workspace_tenant_id": entry.workspace.tenant_id,
"workspace_slug": entry.workspace.slug,
"workspace_is_default": entry.workspace.is_default,
"qualified_name": entry.qualified_name,
}
)
if local_list:
for project in sorted(local_list.projects, key=lambda item: item.permalink):
if project.permalink in cloud_permalinks:
continue
merged.append(
{
"name": project.name,
"path": project.path,
"local_path": project.path,
"cloud_path": None,
"source": "local",
"is_default": project.is_default,
"is_private": project.is_private,
"display_name": project.display_name,
"workspace_name": None,
"workspace_type": None,
"workspace_tenant_id": None,
"workspace_slug": None,
"workspace_is_default": False,
"qualified_name": None,
}
)
return merged
def _format_project_list_text(merged: list[dict]) -> str:
"""Format merged project list as human-readable text."""
result = "Available projects:\n"
current_workspace: tuple[str | None, str | None] | None = None
for project in merged:
workspace_slug = project.get("workspace_slug")
workspace_name = project.get("workspace_name")
if workspace_slug:
workspace_key = (workspace_slug, workspace_name)
if workspace_key != current_workspace:
default_label = " default" if project.get("workspace_is_default") else ""
result += f"\nWorkspace: {workspace_name} ({workspace_slug}{default_label})\n"
current_workspace = workspace_key
elif current_workspace is not None:
result += "\nLocal projects:\n"
current_workspace = None
display_name = project["display_name"]
name = project["name"]
label = f"{display_name} ({name})" if display_name else name
source = project["source"]
result += f"{label} ({source})\n"
qualified_name = project.get("qualified_name")
qualified_suffix = f" [{qualified_name}]" if qualified_name else ""
result += f"- {label} ({source}){qualified_suffix}\n"
result += "\n" + "" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
@@ -207,6 +317,8 @@ async def list_memory_projects(
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
cloud_ws_tenant_id: str | None = None
cloud_ws_slug: str | None = None
cloud_ws_is_default = False
try:
from basic_memory.mcp.project_context import get_available_workspaces
@@ -222,6 +334,8 @@ async def list_memory_projects(
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
cloud_ws_tenant_id = matched.tenant_id
cloud_ws_slug = matched.slug
cloud_ws_is_default = matched.is_default
except Exception:
pass # workspace lookup is best-effort
@@ -231,6 +345,8 @@ async def list_memory_projects(
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
cloud_workspace_slug=cloud_ws_slug,
cloud_workspace_is_default=cloud_ws_is_default,
)
if output_format == "json":
return _format_project_list_json(
@@ -248,39 +364,63 @@ async def list_memory_projects(
# Fetch cloud projects when credentials are available
cloud_list: ProjectList | None = None
cloud_entries: tuple[WorkspaceProjectEntry, ...] = ()
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
cloud_ws_tenant_id: str | None = None
cloud_ws_slug: str | None = None
cloud_ws_is_default = False
config = ConfigManager().config
if has_cloud_credentials(config):
# Use explicit workspace, fall back to config default
effective_workspace = workspace or config.default_workspace
cloud_list = await _fetch_cloud_projects(effective_workspace, context)
# Resolve workspace metadata so each cloud project carries its workspace info
if cloud_list:
cloud_ws_tenant_id = effective_workspace
if workspace:
try:
from basic_memory.mcp.project_context import get_available_workspaces
workspaces = await get_available_workspaces(context)
matched = next(
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
None,
active_workspace = await resolve_workspace_parameter(workspace, context)
except Exception as exc:
logger.warning(
f"Cloud workspace discovery failed while listing projects for "
f"workspace '{workspace}'; trying direct workspace routing before "
f"falling back to local-only project list: {exc}"
)
if matched:
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
except Exception:
pass # workspace lookup is best-effort
if context: # pragma: no cover
await context.info(
"Cloud workspace discovery failed while listing projects; "
"trying direct workspace routing"
)
cloud_list = await _fetch_cloud_projects(workspace, context)
else:
cloud_list = await _fetch_cloud_projects(active_workspace.tenant_id, context)
cloud_ws_name = active_workspace.name
cloud_ws_type = active_workspace.workspace_type
cloud_ws_tenant_id = active_workspace.tenant_id
cloud_ws_slug = active_workspace.slug
cloud_ws_is_default = active_workspace.is_default
else:
try:
workspace_index = await ensure_workspace_project_index(context=context)
cloud_entries = workspace_index.entries
except Exception as exc:
logger.warning(
f"Cloud workspace project index discovery failed while listing projects; "
f"showing local-only project list: {exc}"
)
if context: # pragma: no cover
await context.info(
"Cloud workspace project discovery failed while listing projects; "
"showing local projects only"
)
merged = _merge_projects(
local_list,
cloud_list,
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
)
if cloud_entries:
merged = _merge_workspace_projects(local_list, cloud_entries)
else:
merged = _merge_projects(
local_list,
cloud_list,
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
cloud_workspace_slug=cloud_ws_slug,
cloud_workspace_is_default=cloud_ws_is_default,
)
default_project = local_list.default_project
if output_format == "json":
@@ -390,6 +530,9 @@ async def create_memory_project(
)
status_response = await project_client.create_project(project_request.model_dump())
from basic_memory.mcp.project_context import invalidate_workspace_project_index
await invalidate_workspace_project_index(context)
if output_format == "json":
new_project = status_response.new_project
@@ -479,6 +622,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
# Delete project using project external_id
status_response = await project_client.delete_project(target_project.external_id)
from basic_memory.mcp.project_context import invalidate_workspace_project_index
await invalidate_workspace_project_index(context)
result = f"{status_response.message}\n\n"
-1
View File
@@ -5,7 +5,6 @@ to the Basic Memory API, with improved error handling and logging.
"""
import typing
from contextlib import contextmanager
from typing import Any, Optional
import logfire
+43 -29
View File
@@ -6,6 +6,42 @@ from fastmcp import Context
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.mcp.server import mcp
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
def _personal_workspace() -> WorkspaceInfo:
"""Return a display-only personal workspace when discovery has no rows.
This keeps list_workspaces friendly for non-teams or local-only users. It is not
the cloud routing source of truth; project-scoped routing still depends on real
workspace discovery and the workspace project index in project_context.
"""
return WorkspaceInfo(
tenant_id="personal",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
has_active_subscription=True,
)
def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListResponse:
"""Build the structured MCP response from the shared cloud workspace schema."""
if not workspaces:
workspaces = [_personal_workspace()]
default_workspace_id = next(
(workspace.tenant_id for workspace in workspaces if workspace.is_default),
None,
)
return WorkspaceListResponse(
workspaces=workspaces,
count=len(workspaces),
default_workspace_id=default_workspace_id,
current_workspace_id=None,
)
@mcp.tool(
@@ -24,40 +60,18 @@ async def list_workspaces(
context: Optional FastMCP context for progress/status logging.
"""
workspaces = await get_available_workspaces(context=context)
response = _workspace_list_response(workspaces)
if output_format == "json":
return {
"workspaces": [
{
"tenant_id": ws.tenant_id,
"name": ws.name,
"workspace_type": ws.workspace_type,
"role": ws.role,
"organization_id": ws.organization_id,
"has_active_subscription": ws.has_active_subscription,
}
for ws in workspaces
],
"count": len(workspaces),
}
return response.model_dump(mode="json")
if not workspaces:
return (
"# No Workspaces Available\n\n"
"No accessible workspaces were found for this account. "
"Ensure the account has an active subscription and tenant access."
)
lines = [
f"# Available Workspaces ({len(workspaces)})",
"",
"Use `workspace` as either the `tenant_id` or unique `name` in project-scoped tool calls.",
"",
]
for workspace in workspaces:
lines = [f"# Available Workspaces ({response.count})", ""]
for workspace in response.workspaces:
default_label = ", default" if workspace.is_default else ""
lines.append(
f"- {workspace.name} "
f"(type={workspace.workspace_type}, role={workspace.role}, tenant_id={workspace.tenant_id})"
f"(slug={workspace.slug}, type={workspace.workspace_type}, "
f"role={workspace.role}{default_label}, tenant_id={workspace.tenant_id})"
)
return "\n".join(lines)
+5
View File
@@ -63,8 +63,10 @@ class WorkspaceInfo(BaseModel):
tenant_id: str = Field(..., description="Workspace tenant identifier")
workspace_type: str = Field(..., description="Workspace type (personal or organization)")
slug: str = Field(..., description="Stable workspace slug for qualified project routing")
name: str = Field(..., description="Workspace display name")
role: str = Field(..., description="Current user's role in the workspace")
is_default: bool = Field(..., description="Whether this is the default cloud workspace")
organization_id: str | None = Field(None, description="Organization ID for org workspaces")
has_active_subscription: bool = Field(
default=False, description="Whether the workspace has an active subscription"
@@ -78,6 +80,9 @@ class WorkspaceListResponse(BaseModel):
default_factory=list, description="Available workspaces"
)
count: int = Field(default=0, description="Number of available workspaces")
default_workspace_id: str | None = Field(
default=None, description="Default workspace tenant ID when available"
)
current_workspace_id: str | None = Field(
default=None, description="Current workspace tenant ID when available"
)
+27 -4
View File
@@ -15,6 +15,27 @@ from basic_memory.schemas.project_info import ProjectStatusResponse
import basic_memory.cli.commands.project as project_cmd # noqa: F401
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
):
from basic_memory.schemas.cloud import WorkspaceInfo
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
@pytest.fixture
def runner():
return CliRunner()
@@ -202,17 +223,18 @@ def test_project_add_cloud_workspace_resolves_and_persists(
runner, mock_config, mock_api_client, monkeypatch, tmp_path
):
"""Cloud project add should resolve workspace names to tenant IDs."""
from basic_memory.schemas.cloud import WorkspaceInfo
local_sync_dir = tmp_path / "sync" / "team-notes"
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="organization",
slug="basic-memory",
name="Basic Memory",
role="owner",
is_default=True,
),
]
@@ -257,15 +279,16 @@ def test_project_add_cloud_workspace_persists_without_local_path(
runner, mock_config, mock_api_client, monkeypatch
):
"""Cloud project add should persist workspace routing even without local sync."""
from basic_memory.schemas.cloud import WorkspaceInfo
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="organization",
slug="basic-memory",
name="Basic Memory",
role="owner",
is_default=True,
),
]
+22 -1
View File
@@ -136,6 +136,25 @@ def _cloud_index_status(
)
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
) -> WorkspaceInfo:
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
def test_project_info_local_output_is_unchanged(runner: CliRunner, write_config, monkeypatch):
"""Local project info should not attempt cloud augmentation."""
write_config(
@@ -432,11 +451,13 @@ async def test_resolve_cloud_status_workspace_id_async_auto_discovers_single_wor
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
]
+27 -4
View File
@@ -11,6 +11,27 @@ from basic_memory.cli.app import app
import basic_memory.cli.commands.project # noqa: F401
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
):
from basic_memory.schemas.cloud import WorkspaceInfo
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
@pytest.fixture
def runner():
return CliRunner()
@@ -196,7 +217,6 @@ class TestSetCloudWithWorkspace:
def test_set_cloud_with_workspace_stores_workspace_id(self, runner, mock_config, monkeypatch):
"""Test that --workspace resolves to tenant_id and stores it."""
from basic_memory import config as config_module
from basic_memory.schemas.cloud import WorkspaceInfo
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
@@ -204,11 +224,13 @@ class TestSetCloudWithWorkspace:
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
]
@@ -235,7 +257,6 @@ class TestSetCloudWithWorkspace:
def test_set_cloud_with_workspace_not_found(self, runner, mock_config, monkeypatch):
"""Test --workspace with unknown workspace name."""
from basic_memory import config as config_module
from basic_memory.schemas.cloud import WorkspaceInfo
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
@@ -243,11 +264,13 @@ class TestSetCloudWithWorkspace:
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
]
+32 -5
View File
@@ -16,6 +16,25 @@ import basic_memory.cli.commands.cloud as cloud_cmd # noqa: F401
import basic_memory.cli.commands.cloud.workspace as workspace_cmd # noqa: F401
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
) -> WorkspaceInfo:
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
@pytest.fixture
def runner():
return CliRunner()
@@ -24,15 +43,18 @@ def runner():
def test_workspace_list_prints_available_workspaces(runner, monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
WorkspaceInfo(
_workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
),
@@ -88,11 +110,13 @@ class TestWorkspaceSetDefault:
def test_set_default_workspace_by_name(self, runner, monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
]
@@ -116,9 +140,10 @@ class TestWorkspaceSetDefault:
def test_set_default_workspace_by_tenant_id(self, runner, monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
WorkspaceInfo(
_workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
),
@@ -138,11 +163,13 @@ class TestWorkspaceSetDefault:
def test_set_default_workspace_not_found(self, runner, monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
]
+519 -87
View File
@@ -6,6 +6,7 @@ test config file and pytest monkeypatch for environment variables.
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, cast
import pytest
@@ -23,11 +24,53 @@ class _ContextState:
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
async def info(self, message: str) -> None:
self._state["info_message"] = message
def _ctx(context: _ContextState) -> Any:
return cast(Any, context)
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
):
from basic_memory.schemas.cloud import WorkspaceInfo
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
def _project(
name: str,
*,
id: int = 1,
external_id: str = "11111111-1111-1111-1111-111111111111",
is_default: bool = False,
):
from basic_memory.schemas.project_info import ProjectItem
return ProjectItem(
id=id,
external_id=external_id,
name=name,
path=f"/{name}",
is_default=is_default,
)
@pytest.mark.asyncio
async def test_returns_none_when_no_default_and_no_project(config_manager, monkeypatch):
from basic_memory.mcp.project_context import resolve_project_parameter
@@ -174,14 +217,15 @@ async def test_env_constraint_overrides_default(config_manager, config_home, mon
@pytest.mark.asyncio
async def test_workspace_auto_selects_single_and_caches(monkeypatch):
from basic_memory.mcp.project_context import resolve_workspace_parameter
from basic_memory.schemas.cloud import WorkspaceInfo
context = _ContextState()
only_workspace = WorkspaceInfo(
only_workspace = _workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
async def fake_get_available_workspaces(context=None):
@@ -200,18 +244,20 @@ async def test_workspace_auto_selects_single_and_caches(monkeypatch):
@pytest.mark.asyncio
async def test_workspace_requires_user_choice_when_multiple(monkeypatch):
from basic_memory.mcp.project_context import resolve_workspace_parameter
from basic_memory.schemas.cloud import WorkspaceInfo
workspaces = [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
WorkspaceInfo(
_workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
),
@@ -232,20 +278,22 @@ async def test_workspace_requires_user_choice_when_multiple(monkeypatch):
@pytest.mark.asyncio
async def test_workspace_explicit_selection_by_tenant_id_or_name(monkeypatch):
from basic_memory.mcp.project_context import resolve_workspace_parameter
from basic_memory.schemas.cloud import WorkspaceInfo
team_workspace = WorkspaceInfo(
team_workspace = _workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
)
workspaces = [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
team_workspace,
]
@@ -268,14 +316,15 @@ async def test_workspace_explicit_selection_by_tenant_id_or_name(monkeypatch):
@pytest.mark.asyncio
async def test_workspace_invalid_selection_lists_choices(monkeypatch):
from basic_memory.mcp.project_context import resolve_workspace_parameter
from basic_memory.schemas.cloud import WorkspaceInfo
workspaces = [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
]
@@ -294,13 +343,14 @@ async def test_workspace_invalid_selection_lists_choices(monkeypatch):
@pytest.mark.asyncio
async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
from basic_memory.mcp.project_context import resolve_workspace_parameter
from basic_memory.schemas.cloud import WorkspaceInfo
cached_workspace = WorkspaceInfo(
cached_workspace = _workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
context = _ContextState()
await context.set_state("active_workspace", cached_workspace.model_dump())
@@ -317,6 +367,302 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
assert resolved.tenant_id == cached_workspace.tenant_id
@pytest.mark.asyncio
async def test_workspace_project_index_caches_and_invalidates(monkeypatch):
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_ensure_workspace_project_index,
invalidate_workspace_project_index,
)
context = _ContextState()
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
acme = _workspace(
tenant_id="acme-tenant",
workspace_type="organization",
slug="acme",
name="Acme",
role="editor",
)
calls: list[str] = []
async def fake_get_available_workspaces(context=None):
return [personal, acme]
async def fake_fetch_workspace_project_entries(workspace, context=None):
calls.append(workspace.slug)
project = _project(
f"{workspace.slug}-notes",
id=len(calls),
external_id=f"{workspace.slug}-project-id",
)
return (WorkspaceProjectEntry(workspace=workspace, project=project),)
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
monkeypatch.setattr(
project_context,
"_fetch_workspace_project_entries",
fake_fetch_workspace_project_entries,
)
first = await _ensure_workspace_project_index(context=_ctx(context))
second = await _ensure_workspace_project_index(context=_ctx(context))
assert [entry.qualified_name for entry in first.entries] == [
"personal/personal-notes",
"acme/acme-notes",
]
assert second.entries == first.entries
assert calls == ["personal", "acme"]
await invalidate_workspace_project_index(_ctx(context))
await _ensure_workspace_project_index(context=_ctx(context))
assert calls == ["personal", "acme", "personal", "acme"]
@pytest.mark.asyncio
async def test_workspace_project_index_keeps_successes_when_workspace_fetch_fails(
monkeypatch,
):
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_ensure_workspace_project_index,
resolve_workspace_project_identifier,
)
context = _ContextState()
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
acme = _workspace(
tenant_id="acme-tenant",
workspace_type="organization",
slug="acme",
name="Acme",
role="editor",
)
project = _project("Meeting Notes", id=7, external_id="personal-meeting-notes")
async def fake_get_available_workspaces(context=None):
return [personal, acme]
async def fake_fetch_workspace_project_entries(workspace, context=None):
if workspace.slug == "acme":
raise RuntimeError("acme unavailable")
return (WorkspaceProjectEntry(workspace=workspace, project=project),)
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
monkeypatch.setattr(
project_context,
"_fetch_workspace_project_entries",
fake_fetch_workspace_project_entries,
)
index = await _ensure_workspace_project_index(context=_ctx(context))
assert [entry.qualified_name for entry in index.entries] == ["personal/meeting-notes"]
assert [workspace.slug for workspace in index.failed_workspaces] == ["acme"]
resolved = await resolve_workspace_project_identifier(
"personal/meeting-notes",
context=_ctx(context),
)
assert resolved.project.external_id == "personal-meeting-notes"
with pytest.raises(ValueError, match="Use 'personal/meeting-notes'"):
await resolve_workspace_project_identifier(
"meeting-notes",
context=_ctx(context),
)
@pytest.mark.asyncio
async def test_workspace_project_index_raises_when_all_workspace_fetches_fail(
monkeypatch,
):
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import _ensure_workspace_project_index
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
async def fake_get_available_workspaces(context=None):
return [personal]
async def fake_fetch_workspace_project_entries(workspace, context=None):
raise RuntimeError("tenant unavailable")
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
monkeypatch.setattr(
project_context,
"_fetch_workspace_project_entries",
fake_fetch_workspace_project_entries,
)
with pytest.raises(ValueError, match="Unable to discover projects"):
await _ensure_workspace_project_index()
@pytest.mark.asyncio
async def test_fetch_workspace_project_entries_copies_default_project(monkeypatch):
import basic_memory.mcp.async_client as async_client
from basic_memory.mcp.project_context import _fetch_workspace_project_entries
from basic_memory.schemas.project_info import ProjectList
workspace = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project = _project("Default Notes", id=3, external_id="default-notes-id")
project_list = ProjectList(projects=[project], default_project="Default Notes")
@asynccontextmanager
async def fake_get_client(*args, **kwargs) -> AsyncIterator[object]:
yield object()
async def fake_list_projects(self):
return project_list
monkeypatch.setattr(async_client, "is_factory_mode", lambda: True)
monkeypatch.setattr(async_client, "get_client", fake_get_client)
monkeypatch.setattr(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
fake_list_projects,
)
entries = await _fetch_workspace_project_entries(workspace)
assert project.is_default is False
assert entries[0].project is not project
assert entries[0].project.is_default is True
@pytest.mark.asyncio
async def test_resolve_workspace_project_identifier_handles_qualified_and_collisions(monkeypatch):
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
resolve_workspace_project_identifier,
)
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
acme = _workspace(
tenant_id="acme-tenant",
workspace_type="organization",
slug="acme",
name="Acme",
role="editor",
)
entries = (
WorkspaceProjectEntry(
workspace=personal,
project=_project("Meeting Notes", id=1, external_id="personal-project-id"),
),
WorkspaceProjectEntry(
workspace=acme,
project=_project("Meeting Notes", id=2, external_id="acme-project-id"),
),
)
index = _build_workspace_project_index((personal, acme), entries)
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
resolved = await resolve_workspace_project_identifier("acme/meeting-notes")
assert resolved.workspace.slug == "acme"
assert resolved.project.external_id == "acme-project-id"
with pytest.raises(ValueError, match="Use: personal/meeting-notes or acme/meeting-notes"):
await resolve_workspace_project_identifier("meeting-notes")
@pytest.mark.asyncio
async def test_resolve_workspace_project_identifier_uses_active_workspace_for_duplicate(
monkeypatch,
):
import basic_memory.mcp.project_context as project_context
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
resolve_workspace_project_identifier,
)
context = _ContextState()
personal = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
acme = _workspace(
tenant_id="acme-tenant",
workspace_type="organization",
slug="acme",
name="Acme",
role="editor",
)
await context.set_state("active_workspace", acme.model_dump())
entries = (
WorkspaceProjectEntry(
workspace=personal,
project=_project("Meeting Notes", id=1, external_id="personal-project-id"),
),
WorkspaceProjectEntry(
workspace=acme,
project=_project("Meeting Notes", id=2, external_id="acme-project-id"),
),
)
index = _build_workspace_project_index((personal, acme), entries)
async def fake_index(context=None):
return index
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
resolved = await resolve_workspace_project_identifier(
"meeting-notes",
context=_ctx(context),
)
assert resolved.workspace.slug == "acme"
assert resolved.project.external_id == "acme-project-id"
@pytest.mark.asyncio
async def test_resolve_project_parameter_uses_cached_active_project_before_api_default_lookup(
config_manager, monkeypatch
@@ -599,114 +945,188 @@ class TestGetProjectClientRoutingOrder:
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
# Patch resolve_workspace_parameter to fail if called — it should be skipped
async def fail_if_called(**kwargs): # pragma: no cover
raise AssertionError(
"resolve_workspace_parameter should not be called when workspace_id is set"
)
from contextlib import asynccontextmanager
from basic_memory.schemas.project_info import ProjectItem
seen: dict[str, object] = {}
async def fail_resolve_workspace_parameter(workspace=None, context=None):
raise AssertionError("Configured workspace_id should route without workspace discovery")
monkeypatch.setattr(
"basic_memory.mcp.project_context.resolve_workspace_parameter",
fail_if_called,
fail_resolve_workspace_parameter,
)
# Will fail at cloud client creation (no real cloud), but proves workspace
# resolution was skipped
with pytest.raises(Exception) as exc_info:
async with get_project_client(project="cloud-proj"):
pass
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
seen["project_name"] = project_name
seen["workspace"] = workspace
yield object()
# Should not be a workspace resolution error
error_msg = str(exc_info.value).lower()
assert "resolve_workspace_parameter should not be called" not in error_msg
async def fake_get_active_project(client, project_name, context=None, headers=None):
return ProjectItem(
id=1,
external_id="cloud-project-id",
name=project_name,
path="/cloud-proj",
is_default=False,
)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_active_project",
fake_get_active_project,
)
async with get_project_client(project="cloud-proj") as (_client, active_project):
assert active_project.external_id == "cloud-project-id"
assert seen == {"project_name": "cloud-proj", "workspace": "per-project-tenant-id"}
@pytest.mark.asyncio
async def test_cloud_project_uses_default_workspace(self, config_manager, monkeypatch):
"""Cloud project without workspace_id should fall back to default_workspace."""
async def test_cloud_project_uses_workspace_project_index(self, config_manager, monkeypatch):
"""Cloud project without workspace_id resolves its workspace from the project index."""
from contextlib import asynccontextmanager
from basic_memory.mcp.project_context import get_project_client
from basic_memory.config import ProjectEntry, ProjectMode
from basic_memory.schemas.project_info import ProjectItem
config = config_manager.load_config()
config.projects["cloud-proj"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "cloud-proj"),
mode=ProjectMode.CLOUD,
)
config.default_workspace = "global-default-tenant-id"
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
# Patch resolve_workspace_parameter to fail if called — it should be skipped
async def fail_if_called(**kwargs): # pragma: no cover
raise AssertionError(
"resolve_workspace_parameter should not be called when default_workspace is set"
workspace = _workspace(
tenant_id="acme-tenant",
workspace_type="organization",
slug="acme",
name="Acme",
role="editor",
)
project = _project("Cloud Proj", id=42, external_id="cloud-project-id")
seen: dict[str, object] = {}
async def fake_resolve_workspace_project_identifier(project_name, context=None):
from basic_memory.mcp.project_context import WorkspaceProjectEntry
assert project_name == "cloud-proj"
return WorkspaceProjectEntry(workspace=workspace, project=project)
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
seen["project_name"] = project_name
seen["workspace"] = workspace
yield object()
async def fake_get_active_project(client, project_name, context=None, headers=None):
assert project_name == "Cloud Proj"
return ProjectItem(
id=project.id,
external_id=project.external_id,
name=project.name,
path=project.path,
is_default=False,
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.resolve_workspace_parameter",
fail_if_called,
"basic_memory.mcp.project_context.resolve_workspace_project_identifier",
fake_resolve_workspace_project_identifier,
)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_active_project", fake_get_active_project
)
# Will fail at cloud client creation, but proves workspace resolution was skipped
with pytest.raises(Exception) as exc_info:
async with get_project_client(project="cloud-proj"):
pass
async with get_project_client(project="cloud-proj") as (_client, active_project):
assert active_project.external_id == "cloud-project-id"
error_msg = str(exc_info.value).lower()
assert "resolve_workspace_parameter should not be called" not in error_msg
assert seen == {"project_name": "Cloud Proj", "workspace": "acme-tenant"}
@pytest.mark.asyncio
async def test_cloud_only_project_routes_to_cloud(self, config_manager, monkeypatch):
"""Project NOT in local config should route to cloud (not default to LOCAL).
Cloud-only projects aren't registered in local config. The routing logic
should detect this and use CLOUD mode, falling back to default_workspace.
should detect this and resolve the owning workspace from the cloud index.
"""
from contextlib import asynccontextmanager
from basic_memory.mcp.project_context import get_project_client
from basic_memory.schemas.project_info import ProjectItem
config = config_manager.load_config()
# Do NOT add "cloud-only-proj" to config.projects — it's cloud-only
config.default_workspace = "global-default-tenant-id"
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
# Patch resolve_workspace_parameter to fail if called — it should be skipped
# because default_workspace is set (priority 3)
async def fail_if_called(**kwargs): # pragma: no cover
raise AssertionError(
"resolve_workspace_parameter should not be called when default_workspace is set"
workspace = _workspace(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project = _project("Cloud Only Proj", id=5, external_id="cloud-only-id")
seen: dict[str, object] = {}
async def fake_resolve_workspace_project_identifier(project_name, context=None):
from basic_memory.mcp.project_context import WorkspaceProjectEntry
assert project_name == "cloud-only-proj"
return WorkspaceProjectEntry(workspace=workspace, project=project)
@asynccontextmanager
async def fake_get_client(project_name=None, workspace=None):
seen["project_name"] = project_name
seen["workspace"] = workspace
yield object()
async def fake_get_active_project(client, project_name, context=None, headers=None):
return ProjectItem(
id=project.id,
external_id=project.external_id,
name=project.name,
path=project.path,
is_default=False,
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.resolve_workspace_parameter",
fail_if_called,
"basic_memory.mcp.project_context.resolve_workspace_project_identifier",
fake_resolve_workspace_project_identifier,
)
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_active_project",
fake_get_active_project,
)
# Will fail at cloud client creation (no real cloud), but proves cloud routing
# was selected instead of local routing
with pytest.raises(Exception) as exc_info:
async with get_project_client(project="cloud-only-proj"):
pass
async with get_project_client(project="cloud-only-proj") as (_client, active_project):
assert active_project.external_id == "cloud-only-id"
# The error should NOT be about workspace resolution or local routing
error_msg = str(exc_info.value).lower()
assert "resolve_workspace_parameter should not be called" not in error_msg
# Should not get a local ASGI routing error
assert "no project found" not in error_msg
assert seen == {"project_name": "Cloud Only Proj", "workspace": "personal-tenant"}
@pytest.mark.asyncio
async def test_factory_mode_skips_workspace_resolution(self, config_manager, monkeypatch):
"""When a client factory is set (in-process cloud server), skip workspace resolution.
async def test_factory_mode_uses_workspace_index_without_control_plane(
self, config_manager, monkeypatch
):
"""Factory mode resolves workspace locally and avoids control-plane HTTP.
The cloud MCP server calls set_client_factory() so that get_client() routes
requests through TenantASGITransport. In this mode, workspace and tenant context
are already resolved by the transport layer. Attempting cloud workspace resolution
would call the production control-plane API and fail with 401.
requests through TenantASGITransport. Workspace discovery comes from the
injected provider/index path, not from the production control-plane API.
"""
from contextlib import asynccontextmanager
from basic_memory.mcp import async_client
from basic_memory.mcp.project_context import get_project_client
from basic_memory.config import ProjectEntry, ProjectMode
from basic_memory.schemas.project_info import ProjectItem
config = config_manager.load_config()
config.projects["cloud-proj"] = ProjectEntry(
@@ -715,28 +1135,47 @@ class TestGetProjectClientRoutingOrder:
)
config_manager.save_config(config)
workspace = _workspace(
tenant_id="team-tenant",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
)
project = _project("Cloud Proj", id=9, external_id="factory-project-id")
# Set up a factory (simulates what cloud MCP server does)
@asynccontextmanager
async def fake_factory(workspace: Any = None) -> AsyncIterator[Any]:
from httpx import ASGITransport, AsyncClient
from basic_memory.api.app import app as fastapi_app
async with AsyncClient(
transport=ASGITransport(app=fastapi_app),
base_url="http://test",
) as client:
yield client
assert workspace == "team-tenant"
yield object()
original_factory = async_client._client_factory
async_client.set_client_factory(fake_factory)
# Patch workspace resolution to fail if called — factory mode should skip it
async def fail_if_called(**kwargs): # pragma: no cover
raise AssertionError("resolve_workspace_parameter must not be called in factory mode")
async def fake_resolve_workspace_project_identifier(project_name, context=None):
from basic_memory.mcp.project_context import WorkspaceProjectEntry
assert project_name == "cloud-proj"
return WorkspaceProjectEntry(workspace=workspace, project=project)
async def fake_get_active_project(client, project_name, context=None, headers=None):
assert project_name == "Cloud Proj"
return ProjectItem(
id=project.id,
external_id=project.external_id,
name=project.name,
path=project.path,
is_default=False,
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.resolve_workspace_parameter",
fail_if_called,
"basic_memory.mcp.project_context.resolve_workspace_project_identifier",
fake_resolve_workspace_project_identifier,
)
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_active_project",
fake_get_active_project,
)
# Patch get_cloud_control_plane_client to fail if called
@@ -753,15 +1192,8 @@ class TestGetProjectClientRoutingOrder:
)
try:
# Will fail at project validation (no real project in DB), but proves
# workspace resolution and control-plane calls were skipped
with pytest.raises(Exception) as exc_info:
async with get_project_client(project="cloud-proj"):
pass
error_msg = str(exc_info.value).lower()
assert "resolve_workspace_parameter must not be called" not in error_msg
assert "get_cloud_control_plane_client must not be called" not in error_msg
async with get_project_client(project="cloud-proj") as (_client, active_project):
assert active_project.external_id == "factory-project-id"
finally:
# Restore original factory to avoid polluting other tests
async_client._client_factory = original_factory
@@ -46,8 +46,10 @@ async def test_resolve_workspace_parameter_emits_routing_span(monkeypatch) -> No
workspace = WorkspaceInfo(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
async def fake_get_available_workspaces(context=None):
+101 -32
View File
@@ -47,7 +47,7 @@ def _make_list(projects: list[ProjectItem], default: str | None = None) -> Proje
async def test_list_memory_projects_unconstrained(app, test_project):
result = await list_memory_projects()
assert "Available projects:" in result
assert f" {test_project.name}" in result
assert f"- {test_project.name}" in result
@pytest.mark.asyncio
@@ -77,9 +77,9 @@ async def test_list_memory_projects_shows_display_name(app, client, test_project
result = await list_memory_projects()
# Regular project shows name with source label
assert " main (local)" in result
assert "- main (local)" in result
# Private project shows display_name with slug in parentheses, then source
assert " My Notes (private-fb83af23) (local)" in result
assert "- My Notes (private-fb83af23) (local)" in result
@pytest.mark.asyncio
@@ -95,7 +95,7 @@ async def test_list_memory_projects_no_display_name_shows_name_only(app, client,
):
result = await list_memory_projects()
assert " my-project (local)" in result
assert "- my-project (local)" in result
@pytest.mark.asyncio
@@ -149,7 +149,13 @@ async def test_list_memory_projects_local_and_cloud_merge(app, test_project):
cloud_llc = _make_project(
"basic-memory-llc", "/basic-memory-llc", id=11, external_id="cloud-llc-uuid"
)
cloud_list = _make_list([cloud_main, cloud_llc], default="main")
workspace = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
workspace_index = _make_workspace_index([(workspace, [cloud_main, cloud_llc])])
with (
patch(
@@ -162,19 +168,19 @@ async def test_list_memory_projects_local_and_cloud_merge(app, test_project):
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management._fetch_cloud_projects",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
return_value=cloud_list,
return_value=workspace_index,
),
):
result = await list_memory_projects()
# Both local+cloud project shows merged source
assert " main (local+cloud)" in result
assert "- main (local+cloud)" in result
# Local-only project
assert " specs (local)" in result
assert "- specs (local)" in result
# Cloud-only project
assert " basic-memory-llc (cloud)" in result
assert "- basic-memory-llc (cloud)" in result
@pytest.mark.asyncio
@@ -187,7 +193,7 @@ async def test_list_memory_projects_no_cloud_credentials(app, test_project):
result = await list_memory_projects()
assert "Available projects:" in result
assert f" {test_project.name} (local)" in result
assert f"- {test_project.name} (local)" in result
# No cloud source labels
assert "cloud)" not in result
@@ -201,15 +207,44 @@ async def test_list_memory_projects_cloud_failure_graceful(app, test_project):
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management._fetch_cloud_projects",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
return_value=None,
side_effect=RuntimeError("cloud unavailable"),
),
):
result = await list_memory_projects()
assert "Available projects:" in result
assert f" {test_project.name} (local)" in result
assert f"- {test_project.name} (local)" in result
@pytest.mark.asyncio
async def test_list_memory_projects_explicit_workspace_discovery_failure_graceful(
app,
test_project,
):
"""Explicit workspace discovery failure still returns the local project list."""
with (
patch(
"basic_memory.mcp.tools.project_management.has_cloud_credentials",
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
side_effect=RuntimeError("workspace discovery unavailable"),
),
patch(
"basic_memory.mcp.tools.project_management._fetch_cloud_projects",
new_callable=AsyncMock,
return_value=None,
) as mock_fetch,
):
result = await list_memory_projects(workspace="tenant-abc")
mock_fetch.assert_awaited_once_with("tenant-abc", None)
assert "Available projects:" in result
assert f"- {test_project.name} (local)" in result
@pytest.mark.asyncio
@@ -238,7 +273,7 @@ async def test_list_memory_projects_factory_mode(app, test_project):
):
result = await list_memory_projects()
assert " cloud-proj (cloud)" in result
assert "- cloud-proj (cloud)" in result
@pytest.mark.asyncio
@@ -277,6 +312,9 @@ async def test_list_memory_projects_factory_mode_json_includes_workspace(app, te
assert proj["workspace_name"] == "My Org"
assert proj["workspace_type"] == "organization"
assert proj["workspace_tenant_id"] == "tenant-abc"
assert proj["workspace_slug"] == "my-org"
assert proj["workspace_is_default"] is False
assert proj["qualified_name"] == "my-org/cloud-proj"
@pytest.mark.asyncio
@@ -304,7 +342,7 @@ async def test_list_memory_projects_factory_mode_workspace_lookup_failure(app, t
result = await list_memory_projects()
# Still reported as cloud even without workspace metadata
assert " cloud-proj (cloud)" in result
assert "- cloud-proj (cloud)" in result
@pytest.mark.asyncio
@@ -349,7 +387,13 @@ async def test_list_memory_projects_json_with_cloud(app, test_project):
cloud_main = _make_project("main", "/main", id=10, external_id="cloud-main-uuid")
cloud_only = _make_project("cloud-only", "/cloud-only", id=11, external_id="cloud-only-uuid")
cloud_list = _make_list([cloud_main, cloud_only], default="main")
workspace = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
workspace_index = _make_workspace_index([(workspace, [cloud_main, cloud_only])])
with (
patch(
@@ -362,9 +406,9 @@ async def test_list_memory_projects_json_with_cloud(app, test_project):
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management._fetch_cloud_projects",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
return_value=cloud_list,
return_value=workspace_index,
),
):
result = await list_memory_projects(output_format="json")
@@ -391,6 +435,8 @@ async def test_list_memory_projects_json_with_cloud(app, test_project):
assert cloud_proj["local_path"] is None
assert cloud_proj["cloud_path"] == "/cloud-only"
assert cloud_proj["path"] == "/cloud-only"
assert cloud_proj["workspace_slug"] == "personal"
assert cloud_proj["qualified_name"] == "personal/cloud-only"
# --- Unit test for _merge_projects ---
@@ -473,6 +519,8 @@ def _make_workspace(
workspace_type: str = "personal",
role: str = "owner",
organization_id: str | None = None,
slug: str | None = None,
is_default: bool = False,
):
"""Create a WorkspaceInfo for testing."""
from basic_memory.schemas.cloud import WorkspaceInfo
@@ -481,12 +529,30 @@ def _make_workspace(
tenant_id=tenant_id,
name=name,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
role=role,
organization_id=organization_id,
is_default=is_default,
has_active_subscription=True,
)
def _make_workspace_index(workspace_projects):
"""Create a WorkspaceProjectIndex from (workspace, projects) tuples."""
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
workspaces = tuple(workspace for workspace, _projects in workspace_projects)
entries = tuple(
WorkspaceProjectEntry(workspace=workspace, project=project)
for workspace, projects in workspace_projects
for project in projects
)
return _build_workspace_project_index(workspaces, entries)
@pytest.mark.asyncio
async def test_list_memory_projects_passes_explicit_workspace(app, test_project):
"""Explicit workspace param is forwarded to _fetch_cloud_projects."""
@@ -514,9 +580,16 @@ async def test_list_memory_projects_passes_explicit_workspace(app, test_project)
@pytest.mark.asyncio
async def test_list_memory_projects_falls_back_to_config_workspace(app, test_project):
"""When no explicit workspace is given, config.default_workspace is used."""
cloud_list = _make_list([_make_project("cloud-proj", "/cloud-proj")])
async def test_list_memory_projects_aggregates_without_config_workspace(app, test_project):
"""When no explicit workspace is given, cloud discovery fans out across workspaces."""
cloud_project = _make_project("cloud-proj", "/cloud-proj")
workspace = _make_workspace(
"config-default-ws",
"Default WS",
slug="default",
is_default=True,
)
workspace_index = _make_workspace_index([(workspace, [cloud_project])])
with (
patch("basic_memory.mcp.tools.project_management.ConfigManager") as mock_cm_cls,
@@ -525,21 +598,17 @@ async def test_list_memory_projects_falls_back_to_config_workspace(app, test_pro
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management._fetch_cloud_projects",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
return_value=cloud_list,
) as mock_fetch,
patch(
"basic_memory.mcp.project_context.get_available_workspaces",
new_callable=AsyncMock,
return_value=[_make_workspace("config-default-ws", "Default WS")],
),
return_value=workspace_index,
) as mock_index,
):
mock_config = mock_cm_cls.return_value.config
mock_config.default_workspace = "config-default-ws"
await list_memory_projects()
result = await list_memory_projects()
mock_fetch.assert_awaited_once_with("config-default-ws", None)
mock_index.assert_awaited_once()
assert "- cloud-proj (cloud) [default/cloud-proj]" in result
@pytest.mark.asyncio
+80 -9
View File
@@ -9,6 +9,25 @@ from basic_memory.mcp.tools.workspaces import list_workspaces
from basic_memory.schemas.cloud import WorkspaceInfo
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
) -> WorkspaceInfo:
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
class _ContextState:
def __init__(self):
self._state: dict[str, object] = {}
@@ -24,15 +43,18 @@ class _ContextState:
async def test_list_workspaces_formats_workspace_rows(monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
WorkspaceInfo(
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
WorkspaceInfo(
_workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
),
@@ -45,8 +67,45 @@ async def test_list_workspaces_formats_workspace_rows(monkeypatch):
result = await list_workspaces()
assert "# Available Workspaces (2)" in result
assert "Personal (type=personal, role=owner" in result
assert "Team (type=organization, role=editor" in result
assert "Personal (slug=personal, type=personal, role=owner" in result
assert "Team (slug=team, type=organization, role=editor" in result
@pytest.mark.asyncio
async def test_list_workspaces_json_uses_workspace_list_schema(monkeypatch):
async def fake_get_available_workspaces(context=None):
return [
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
_workspace(
tenant_id="22222222-2222-2222-2222-222222222222",
workspace_type="organization",
slug="team",
name="Team",
role="editor",
),
]
monkeypatch.setattr(
"basic_memory.mcp.tools.workspaces.get_available_workspaces",
fake_get_available_workspaces,
)
result = await list_workspaces(output_format="json")
assert isinstance(result, dict)
assert result["count"] == 2
assert result["default_workspace_id"] == "11111111-1111-1111-1111-111111111111"
assert result["current_workspace_id"] is None
assert result["workspaces"][0]["slug"] == "personal"
assert result["workspaces"][0]["is_default"] is True
assert result["workspaces"][1]["slug"] == "team"
@pytest.mark.asyncio
@@ -60,7 +119,14 @@ async def test_list_workspaces_handles_empty_list(monkeypatch):
)
result = await list_workspaces()
assert "# No Workspaces Available" in result
assert "# Available Workspaces (1)" in result
assert "Personal (slug=personal, type=personal, role=owner, default" in result
json_result = await list_workspaces(output_format="json")
assert isinstance(json_result, dict)
assert json_result["count"] == 1
assert json_result["default_workspace_id"] == "personal"
assert json_result["workspaces"][0]["slug"] == "personal"
@pytest.mark.asyncio
@@ -81,9 +147,10 @@ async def test_list_workspaces_oauth_error_bubbles_up(monkeypatch):
async def test_list_workspaces_uses_context_cache_path(monkeypatch):
context = _ContextState()
call_count = {"fetches": 0}
workspace = WorkspaceInfo(
workspace = _workspace(
tenant_id="33333333-3333-3333-3333-333333333333",
workspace_type="personal",
slug="cached",
name="Cached",
role="owner",
)
@@ -126,11 +193,13 @@ def _reset_workspace_provider(monkeypatch):
async def test_get_available_workspaces_uses_provider_when_set():
"""When a workspace provider is injected, it is called instead of the control-plane client."""
expected = [
WorkspaceInfo(
_workspace(
tenant_id="aaaa-bbbb",
workspace_type="personal",
slug="injected",
name="Injected",
role="owner",
is_default=True,
),
]
@@ -163,7 +232,8 @@ async def test_get_available_workspaces_falls_back_without_provider(monkeypatch)
result = await list_workspaces()
assert called["control_plane"]
assert "# No Workspaces Available" in result
assert "# Available Workspaces (1)" in result
assert "Personal (slug=personal, type=personal, role=owner, default" in result
@pytest.mark.asyncio
@@ -171,9 +241,10 @@ async def test_get_available_workspaces_falls_back_without_provider(monkeypatch)
async def test_get_available_workspaces_provider_caches_in_context():
"""Provider results are cached in the MCP context for subsequent calls."""
call_count = {"provider": 0}
workspace = WorkspaceInfo(
workspace = _workspace(
tenant_id="cccc-dddd",
workspace_type="organization",
slug="cached-provider",
name="Cached Provider",
role="editor",
)