mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): discover projects across workspaces (#757)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user