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

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-04-18 14:04:57 -05:00
committed by GitHub
parent 56d6f1b4a5
commit f3e46d7984
13 changed files with 1504 additions and 280 deletions
+471 -79
View File
@@ -8,8 +8,10 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple
from dataclasses import dataclass
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
from httpx import AsyncClient
from httpx._types import (
@@ -20,7 +22,7 @@ from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
import logfire
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
from basic_memory.project_resolver import ProjectResolver
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
from basic_memory.schemas.project_info import ProjectItem, ProjectList
@@ -33,6 +35,29 @@ from basic_memory.utils import generate_permalink, normalize_project_reference
# The cloud MCP server sets a provider that queries its own database directly,
# avoiding the control-plane HTTP round-trip that requires local credentials.
_workspace_provider: Optional[Callable[[], Awaitable[list[WorkspaceInfo]]]] = None
_WORKSPACE_PROJECT_INDEX_STATE_KEY = "workspace_project_index"
@dataclass(frozen=True)
class WorkspaceProjectEntry:
"""A cloud project resolved together with the workspace that owns it."""
workspace: WorkspaceInfo
project: ProjectItem
@property
def qualified_name(self) -> str:
return f"{self.workspace.slug}/{self.project.permalink}"
@dataclass(frozen=True)
class WorkspaceProjectIndex:
"""Session-local cloud project lookup index keyed by project permalink."""
workspaces: tuple[WorkspaceInfo, ...]
entries: tuple[WorkspaceProjectEntry, ...]
entries_by_permalink: dict[str, tuple[WorkspaceProjectEntry, ...]]
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
@@ -88,6 +113,37 @@ async def _set_cached_active_project(
await context.set_state("default_project_name", active_project.name)
async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
"""Return the cached active workspace from context when available."""
if not context:
return None
cached_raw = await context.get_state("active_workspace")
if isinstance(cached_raw, dict):
return WorkspaceInfo.model_validate(cached_raw)
return None
async def _set_cached_active_workspace(
context: Optional[Context],
active_workspace: WorkspaceInfo,
) -> None:
"""Persist workspace context and clear project cache when the tenant changes."""
if not context:
return
cached_workspace = await _get_cached_active_workspace(context)
if cached_workspace and cached_workspace.tenant_id != active_workspace.tenant_id:
# Trigger: project routing moved to another workspace
# Why: project names are only unique inside one workspace, so a cached
# ProjectItem from the previous tenant can point at the wrong project
# Outcome: force the next validation call to resolve within the new tenant
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
await context.set_state("active_workspace", active_workspace.model_dump())
async def _get_cached_default_project(context: Optional[Context]) -> Optional[str]:
"""Return the cached default project name from context when available."""
if not context:
@@ -211,9 +267,11 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
def _workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
"""Return True when identifier matches workspace tenant_id or name."""
"""Return True when identifier matches workspace tenant_id, slug, or name."""
if workspace.tenant_id == identifier:
return True
if workspace.slug.casefold() == identifier.casefold():
return True
return workspace.name.lower() == identifier.lower()
@@ -223,13 +281,115 @@ def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
[
(
f"- {item.name} "
f"(type={item.workspace_type}, role={item.role}, tenant_id={item.tenant_id})"
f"(slug={item.slug}, type={item.workspace_type}, "
f"role={item.role}, tenant_id={item.tenant_id})"
)
for item in workspaces
]
)
def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex | None:
"""Deserialize a cached workspace project index from MCP context state."""
if not isinstance(raw, dict):
return None
raw_mapping = cast(dict[str, object], raw)
workspaces_raw = raw_mapping.get("workspaces")
entries_raw = raw_mapping.get("entries")
if not isinstance(workspaces_raw, list) or not isinstance(entries_raw, list):
return None
workspaces = tuple(WorkspaceInfo.model_validate(item) for item in workspaces_raw)
failed_workspaces_raw = raw_mapping.get("failed_workspaces")
failed_workspaces = (
tuple(WorkspaceInfo.model_validate(item) for item in failed_workspaces_raw)
if isinstance(failed_workspaces_raw, list)
else ()
)
entries_list: list[WorkspaceProjectEntry] = []
for item in entries_raw:
if not isinstance(item, dict):
continue
item_mapping = cast(dict[str, object], item)
workspace_raw = item_mapping.get("workspace")
project_raw = item_mapping.get("project")
if workspace_raw is None or project_raw is None:
continue
entries_list.append(
WorkspaceProjectEntry(
workspace=WorkspaceInfo.model_validate(workspace_raw),
project=ProjectItem.model_validate(project_raw),
)
)
entries = tuple(entries_list)
return _build_workspace_project_index(
workspaces,
entries,
failed_workspaces=failed_workspaces,
)
def _workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict:
"""Serialize a workspace project index for MCP context state."""
return {
"workspaces": [workspace.model_dump() for workspace in index.workspaces],
"failed_workspaces": [workspace.model_dump() for workspace in index.failed_workspaces],
"entries": [
{
"workspace": entry.workspace.model_dump(),
"project": entry.project.model_dump(),
}
for entry in index.entries
],
}
def _build_workspace_project_index(
workspaces: tuple[WorkspaceInfo, ...],
entries: tuple[WorkspaceProjectEntry, ...],
*,
failed_workspaces: tuple[WorkspaceInfo, ...] = (),
) -> WorkspaceProjectIndex:
"""Build the permalink lookup table for workspace-project entries."""
grouped: dict[str, list[WorkspaceProjectEntry]] = {}
for entry in entries:
grouped.setdefault(entry.project.permalink, []).append(entry)
return WorkspaceProjectIndex(
workspaces=workspaces,
entries=entries,
entries_by_permalink={
permalink: tuple(items)
for permalink, items in sorted(grouped.items(), key=lambda item: item[0])
},
failed_workspaces=failed_workspaces,
)
def _split_qualified_project_identifier(identifier: str) -> tuple[str | None, str]:
"""Split ``<workspace-slug>/<project>`` identifiers for cloud routing."""
cleaned = identifier.strip()
if "/" not in cleaned:
return None, cleaned
workspace_slug, project_identifier = cleaned.split("/", 1)
if not workspace_slug or not project_identifier:
return None, cleaned
return workspace_slug, project_identifier
def _unqualified_project_identifier(identifier: str) -> str:
"""Return the project segment from an optional qualified project identifier."""
_, project_identifier = _split_qualified_project_identifier(identifier)
return project_identifier
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
"""Format qualified project choices for collision errors."""
return " or ".join(entry.qualified_name for entry in entries)
async def get_available_workspaces(context: Optional[Context] = None) -> list[WorkspaceInfo]:
"""Load available cloud workspaces for the current authenticated user."""
if context:
@@ -266,6 +426,238 @@ async def get_available_workspaces(context: Optional[Context] = None) -> list[Wo
return workspace_list.workspaces
async def invalidate_workspace_project_index(context: Optional[Context] = None) -> None:
"""Invalidate the cached cloud workspace/project lookup index."""
if context:
await context.set_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY, None)
async def _fetch_workspace_project_entries(
workspace: WorkspaceInfo,
context: Optional[Context] = None,
) -> tuple[WorkspaceProjectEntry, ...]:
"""Fetch projects for one workspace and tag each project with workspace metadata."""
from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode
from basic_memory.mcp.clients import ProjectClient
client_context = (
get_client(workspace=workspace.tenant_id)
if is_factory_mode()
else get_cloud_proxy_client(workspace=workspace.tenant_id)
)
async with client_context as client:
project_list = await ProjectClient(client).list_projects()
default_permalink = (
generate_permalink(project_list.default_project) if project_list.default_project else None
)
entries: list[WorkspaceProjectEntry] = []
for project in project_list.projects:
entry_project = project
if default_permalink and project.permalink == default_permalink and not project.is_default:
entry_project = project.model_copy(update={"is_default": True})
entries.append(WorkspaceProjectEntry(workspace=workspace, project=entry_project))
if context: # pragma: no cover
await context.info(
f"Discovered {len(entries)} cloud projects in workspace {workspace.slug}"
)
return tuple(entries)
async def _ensure_workspace_project_index(
context: Optional[Context] = None,
) -> WorkspaceProjectIndex:
"""Build or load the session-local workspace/project lookup index."""
if context:
cached_raw = await context.get_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY)
cached_index = _workspace_project_index_from_state(cached_raw)
if cached_index is not None:
return cached_index
workspaces = tuple(await get_available_workspaces(context=context))
if not workspaces:
raise ValueError(
"No accessible workspaces found for this account. "
"Ensure you have an active subscription and tenant access."
)
fetched_results = await asyncio.gather(
*[_fetch_workspace_project_entries(workspace, context=context) for workspace in workspaces],
return_exceptions=True,
)
entries_list: list[WorkspaceProjectEntry] = []
failed_workspaces: list[WorkspaceInfo] = []
successful_fetches = 0
for workspace, result in zip(workspaces, fetched_results, strict=True):
if isinstance(result, BaseException):
if not isinstance(result, Exception):
raise result
# Trigger: one workspace project listing failed during a multi-workspace index.
# Why: a transient or unauthorized tenant should not break qualified routing for
# healthy workspaces, but unqualified routing still needs to know the index is partial.
# Outcome: keep successful workspace entries and record the failed workspace.
failed_workspaces.append(workspace)
logger.warning(
f"Cloud project discovery failed for workspace {workspace.slug} "
f"({workspace.tenant_id}): {result}"
)
if context: # pragma: no cover
await context.info(
f"Cloud project discovery failed for workspace {workspace.slug}; "
"continuing with other workspaces"
)
continue
workspace_entries = cast(tuple[WorkspaceProjectEntry, ...], result)
successful_fetches += 1
entries_list.extend(workspace_entries)
if failed_workspaces and successful_fetches == 0:
failed_labels = ", ".join(workspace.slug for workspace in failed_workspaces)
raise ValueError(
"Unable to discover projects in any accessible workspace. "
f"Failed workspaces: {failed_labels}"
)
entries = tuple(entries_list)
index = _build_workspace_project_index(
workspaces,
entries,
failed_workspaces=tuple(failed_workspaces),
)
if context:
await context.set_state(
_WORKSPACE_PROJECT_INDEX_STATE_KEY,
_workspace_project_index_to_state(index),
)
return index
async def ensure_workspace_project_index(
context: Optional[Context] = None,
) -> WorkspaceProjectIndex:
"""Public wrapper for loading the session-local workspace/project lookup index."""
return await _ensure_workspace_project_index(context=context)
async def resolve_workspace_project_identifier(
project: str,
context: Optional[Context] = None,
) -> WorkspaceProjectEntry:
"""Resolve an unqualified or ``<workspace>/<project>`` cloud project identifier."""
index = await _ensure_workspace_project_index(context=context)
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
project_permalink = generate_permalink(project_identifier)
if workspace_slug:
workspace_matches = [
workspace
for workspace in index.workspaces
if workspace.slug.casefold() == workspace_slug.casefold()
]
if not workspace_matches:
available = ", ".join(workspace.slug for workspace in index.workspaces)
raise ValueError(
f"Workspace '{workspace_slug}' was not found. "
f"Available workspace slugs: {available}"
)
workspace = workspace_matches[0]
matches = [
entry
for entry in index.entries_by_permalink.get(project_permalink, ())
if entry.workspace.tenant_id == workspace.tenant_id
]
if not matches:
if any(
failed_workspace.tenant_id == workspace.tenant_id
for failed_workspace in index.failed_workspaces
):
raise ValueError(
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
"could not be loaded. Retry after workspace discovery recovers."
)
available = ", ".join(
entry.qualified_name
for entry in index.entries
if entry.workspace.tenant_id == workspace.tenant_id
)
raise ValueError(
f"Project '{project_identifier}' was not found in workspace "
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
)
return matches[0]
matches = index.entries_by_permalink.get(project_permalink, ())
if not matches:
failed_note = ""
if index.failed_workspaces:
failed = ", ".join(workspace.slug for workspace in index.failed_workspaces)
failed_note = (
f" Project discovery failed for workspace(s): {failed}; "
"retry or use a qualified project from an indexed workspace."
)
available = ", ".join(entry.qualified_name for entry in index.entries)
raise ValueError(
f"Project '{project}' was not found in indexed cloud workspaces. "
f"Available projects: {available}.{failed_note}"
)
cached_workspace = await _get_cached_active_workspace(context)
if cached_workspace:
cached_matches = [
entry for entry in matches if entry.workspace.tenant_id == cached_workspace.tenant_id
]
if cached_matches:
return cached_matches[0]
if len(matches) > 1:
choices = _format_qualified_choices(matches)
details = "\n".join(
f"- {entry.workspace.name} ({entry.workspace.slug}): {entry.qualified_name}"
for entry in matches
)
raise ValueError(
f"Project '{project}' exists in multiple workspaces. Use: {choices}\n{details}"
)
if index.failed_workspaces:
qualified_name = matches[0].qualified_name
failed = ", ".join(workspace.slug for workspace in index.failed_workspaces)
raise ValueError(
f"Project '{project}' was found as {qualified_name}, but project discovery "
f"failed for workspace(s): {failed}. Use '{qualified_name}' to route "
"explicitly, or retry after discovery recovers."
)
return matches[0]
async def _default_workspace_project_entry(
context: Optional[Context] = None,
) -> WorkspaceProjectEntry | None:
"""Return the default project from the default cloud workspace, when available."""
index = await _ensure_workspace_project_index(context=context)
default_workspace = next(
(workspace for workspace in index.workspaces if workspace.is_default),
None,
)
if default_workspace is None:
return None
default_entries = [
entry
for entry in index.entries
if entry.workspace.tenant_id == default_workspace.tenant_id and entry.project.is_default
]
return default_entries[0] if default_entries else None
async def resolve_workspace_parameter(
workspace: Optional[str] = None,
context: Optional[Context] = None,
@@ -320,8 +712,8 @@ async def resolve_workspace_parameter(
f"Available workspaces:\n{_workspace_choices(workspaces)}"
)
await _set_cached_active_workspace(context, selected_workspace)
if context:
await context.set_state("active_workspace", selected_workspace.model_dump())
logger.debug(f"Cached workspace in context: {selected_workspace.tenant_id}")
return selected_workspace
@@ -575,18 +967,16 @@ async def get_project_client(
network), creates the correctly-routed client, then validates via API.
Routing decision order:
1. Explicit --local/--cloud flags → skip workspace, use flag routing
2. Cloud routing (explicit --cloud OR project mode CLOUD) →
resolve workspace via priority chain, create cloud client
3. Otherwise → local ASGI client
1. Explicit --local flag → skip workspace, use local routing
2. Factory/cloud routing → resolve project through workspace/project index
3. Cloud project mode → resolve project through workspace/project index
4. Otherwise → local ASGI client
Workspace resolution priority (when cloud routing):
1. Explicit ``workspace`` parameter
2. Per-project ``workspace_id`` from config
3. Global ``default_workspace`` from config
4. MCP session cache (context)
5. Auto-select if single workspace
6. Error listing choices
3. Qualified project identifier (``<workspace-slug>/<project>``)
4. Workspace/project index lookup with collision detection
Args:
project: Optional explicit project parameter
@@ -610,6 +1000,25 @@ async def get_project_client(
# Step 1: Resolve project name from config (no network call)
resolved_project = await resolve_project_parameter(project, context=context)
config = ConfigManager().config
factory_mode = is_factory_mode()
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
cloud_default_entry: WorkspaceProjectEntry | None = None
if (
resolved_project is None
and not (_explicit_routing() and _force_local_mode())
and (
factory_mode
or explicit_cloud_routing
or (not config.projects and has_cloud_credentials(config))
)
):
cloud_default_entry = await _default_workspace_project_entry(context=context)
if cloud_default_entry is not None:
resolved_project = cloud_default_entry.project.name
await _set_cached_active_workspace(context, cloud_default_entry.workspace)
if not resolved_project:
# Fall back to local client to discover projects and raise helpful error
async with get_client() as client:
@@ -620,26 +1029,6 @@ async def get_project_client(
f"Available projects: {project_names}"
)
# Step 1b: Factory injection (in-process cloud server)
# Trigger: set_client_factory() was called (e.g., by cloud MCP server)
# Why: the factory's transport layer handles auth and tenant resolution;
# we pass workspace through so the transport can route to the correct
# workspace when the tool specifies one different from the connection default
# Outcome: factory client with optional workspace override via inner request headers
if is_factory_mode():
route_mode = "factory"
with logfire.span(
"routing.client_session",
project_name=resolved_project,
route_mode=route_mode,
workspace_id=workspace,
):
logger.debug("Using injected client factory for project routing")
async with get_client(workspace=workspace) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
return
# Step 2: Check explicit routing BEFORE workspace resolution
# Trigger: CLI passed --local or --cloud
# Why: explicit flags must be deterministic — skip workspace entirely for --local
@@ -658,66 +1047,69 @@ async def get_project_client(
return
# Step 3: Determine if cloud routing is needed
config = ConfigManager().config
project_entry = config.projects.get(resolved_project)
project_mode = config.get_project_mode(resolved_project)
# Trigger: workspace provided for a local project (without explicit --cloud)
# Why: workspace selection is a cloud routing concern only
# Outcome: fail fast with a deterministic guidance message
if project_mode != ProjectMode.CLOUD and workspace is not None and not _explicit_routing():
if (
not factory_mode
and project_mode != ProjectMode.CLOUD
and workspace is not None
and not _explicit_routing()
):
raise ValueError(
f"Workspace '{workspace}' cannot be used with local project '{resolved_project}'. "
"Workspace selection is only supported for cloud-mode projects."
)
if project_mode == ProjectMode.CLOUD or (_explicit_routing() and not _force_local_mode()):
# --- Cloud routing: resolve workspace with priority chain ---
effective_workspace = workspace
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
route_mode = "factory" if factory_mode else "cloud_proxy"
active_ws: WorkspaceInfo | None = None
workspace_id: str
project_for_api = _unqualified_project_identifier(resolved_project)
# Priority 2: per-project workspace_id from config
if effective_workspace is None and project_entry and project_entry.workspace_id:
effective_workspace = project_entry.workspace_id
# Priority 3: global default_workspace from config
if effective_workspace is None and config.default_workspace:
effective_workspace = config.default_workspace
route_mode = "cloud_proxy"
# Priorities 4-6: if still unresolved, fall back to resolve_workspace_parameter
# which checks context cache, auto-selects single workspace, or errors
if effective_workspace is not None:
# Config-resolved workspace — pass directly to get_client, skip network lookup
with logfire.span(
"routing.client_session",
project_name=resolved_project,
route_mode=route_mode,
workspace_id=effective_workspace,
):
logger.debug("Using configured workspace for cloud project routing")
async with get_client(
project_name=resolved_project,
workspace=effective_workspace,
) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
# Trigger: a script or config entry pins the tenant explicitly
# Why: explicit tenant configuration remains the escape hatch during migration
# Outcome: route to that workspace, but validate the project name inside it
if workspace is not None:
active_ws = await resolve_workspace_parameter(workspace=workspace, context=context)
workspace_id = active_ws.tenant_id
elif project_entry and project_entry.workspace_id:
# Trigger: the local project config already stores the cloud tenant id.
# Why: routing can send that id directly; requiring workspace discovery here
# would turn a control-plane listing outage into a project routing failure.
# Outcome: preserve project-scoped routing even when discovery is unavailable.
workspace_id = project_entry.workspace_id
else:
# No config-based workspace — use resolve_workspace_parameter for discovery
active_ws = await resolve_workspace_parameter(workspace=None, context=context)
with logfire.span(
"routing.client_session",
project_name=resolved_project,
route_mode=route_mode,
workspace_id=active_ws.tenant_id,
resolved_entry = cloud_default_entry
if resolved_entry is None or not _project_matches_identifier(
resolved_entry.project, resolved_project
):
logger.debug("Resolved workspace dynamically for cloud project routing")
async with get_client(
project_name=resolved_project,
workspace=active_ws.tenant_id,
) as client:
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
resolved_entry = await resolve_workspace_project_identifier(
resolved_project,
context=context,
)
active_ws = resolved_entry.workspace
workspace_id = active_ws.tenant_id
project_for_api = resolved_entry.project.name
if active_ws is not None:
await _set_cached_active_workspace(context, active_ws)
with logfire.span(
"routing.client_session",
project_name=project_for_api,
route_mode=route_mode,
workspace_id=workspace_id,
):
logger.debug("Using resolved workspace for cloud project routing")
async with get_client(
project_name=project_for_api,
workspace=workspace_id,
) as client:
active_project = await get_active_project(client, project_for_api, context)
yield client, active_project
return
# Step 4: Local routing (default)
+175 -29
View File
@@ -12,6 +12,11 @@ from loguru import logger
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
ensure_workspace_project_index,
resolve_workspace_parameter,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList
from basic_memory.utils import generate_permalink
@@ -26,7 +31,8 @@ async def _fetch_cloud_projects(
) -> ProjectList | None:
"""Fetch projects from the cloud API, returning None on failure.
Logs warnings on failure so the caller can fall back to local-only results.
Logs warnings on failure so list_memory_projects can fall back to local-only
results. Project-scoped routing does not use this listing fallback.
"""
try:
from basic_memory.mcp.clients import ProjectClient
@@ -38,9 +44,14 @@ async def _fetch_cloud_projects(
await context.info(f"Discovered {len(cloud_list.projects)} cloud projects")
return cloud_list
except Exception as exc:
logger.warning(f"Cloud project discovery failed: {exc}")
logger.warning(
f"Cloud project discovery failed while listing projects; "
f"showing local-only project list: {exc}"
)
if context: # pragma: no cover
await context.info("Cloud project discovery failed, showing local projects only")
await context.info(
"Cloud project discovery failed while listing projects; showing local projects only"
)
return None
@@ -51,6 +62,8 @@ def _merge_projects(
cloud_workspace_name: str | None = None,
cloud_workspace_type: str | None = None,
cloud_workspace_tenant_id: str | None = None,
cloud_workspace_slug: str | None = None,
cloud_workspace_is_default: bool = False,
) -> list[dict]:
"""Merge local and cloud project lists by permalink.
@@ -126,21 +139,118 @@ def _merge_projects(
"workspace_name": ws_name,
"workspace_type": ws_type,
"workspace_tenant_id": ws_tenant_id,
"workspace_slug": cloud_workspace_slug if cloud_proj else None,
"workspace_is_default": cloud_workspace_is_default if cloud_proj else False,
"qualified_name": (
f"{cloud_workspace_slug}/{permalink}"
if cloud_proj and cloud_workspace_slug
else None
),
}
)
return merged
def _merge_workspace_projects(
local_list: ProjectList | None,
cloud_entries: tuple[WorkspaceProjectEntry, ...],
) -> list[dict]:
"""Merge local projects with cloud projects from every accessible workspace."""
local_by_permalink: dict[str, ProjectItem] = {}
if local_list:
for project in local_list.projects:
local_by_permalink[project.permalink] = project
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
merged: list[dict] = []
for entry in sorted(
cloud_entries,
key=lambda item: (
not item.workspace.is_default,
item.workspace.workspace_type != "personal",
item.workspace.name.casefold(),
item.project.permalink,
),
):
permalink = entry.project.permalink
local_proj = local_by_permalink.get(permalink)
cloud_proj = entry.project
source = "local+cloud" if local_proj else "cloud"
local_path = local_proj.path if local_proj else None
cloud_path = cloud_proj.path
merged.append(
{
"name": cloud_proj.name,
"path": local_path or cloud_path,
"local_path": local_path,
"cloud_path": cloud_path,
"source": source,
"is_default": bool((local_proj and local_proj.is_default) or cloud_proj.is_default),
"is_private": cloud_proj.is_private,
"display_name": cloud_proj.display_name,
"workspace_name": entry.workspace.name,
"workspace_type": entry.workspace.workspace_type,
"workspace_tenant_id": entry.workspace.tenant_id,
"workspace_slug": entry.workspace.slug,
"workspace_is_default": entry.workspace.is_default,
"qualified_name": entry.qualified_name,
}
)
if local_list:
for project in sorted(local_list.projects, key=lambda item: item.permalink):
if project.permalink in cloud_permalinks:
continue
merged.append(
{
"name": project.name,
"path": project.path,
"local_path": project.path,
"cloud_path": None,
"source": "local",
"is_default": project.is_default,
"is_private": project.is_private,
"display_name": project.display_name,
"workspace_name": None,
"workspace_type": None,
"workspace_tenant_id": None,
"workspace_slug": None,
"workspace_is_default": False,
"qualified_name": None,
}
)
return merged
def _format_project_list_text(merged: list[dict]) -> str:
"""Format merged project list as human-readable text."""
result = "Available projects:\n"
current_workspace: tuple[str | None, str | None] | None = None
for project in merged:
workspace_slug = project.get("workspace_slug")
workspace_name = project.get("workspace_name")
if workspace_slug:
workspace_key = (workspace_slug, workspace_name)
if workspace_key != current_workspace:
default_label = " default" if project.get("workspace_is_default") else ""
result += f"\nWorkspace: {workspace_name} ({workspace_slug}{default_label})\n"
current_workspace = workspace_key
elif current_workspace is not None:
result += "\nLocal projects:\n"
current_workspace = None
display_name = project["display_name"]
name = project["name"]
label = f"{display_name} ({name})" if display_name else name
source = project["source"]
result += f"{label} ({source})\n"
qualified_name = project.get("qualified_name")
qualified_suffix = f" [{qualified_name}]" if qualified_name else ""
result += f"- {label} ({source}){qualified_suffix}\n"
result += "\n" + "" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
@@ -207,6 +317,8 @@ async def list_memory_projects(
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
cloud_ws_tenant_id: str | None = None
cloud_ws_slug: str | None = None
cloud_ws_is_default = False
try:
from basic_memory.mcp.project_context import get_available_workspaces
@@ -222,6 +334,8 @@ async def list_memory_projects(
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
cloud_ws_tenant_id = matched.tenant_id
cloud_ws_slug = matched.slug
cloud_ws_is_default = matched.is_default
except Exception:
pass # workspace lookup is best-effort
@@ -231,6 +345,8 @@ async def list_memory_projects(
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
cloud_workspace_slug=cloud_ws_slug,
cloud_workspace_is_default=cloud_ws_is_default,
)
if output_format == "json":
return _format_project_list_json(
@@ -248,39 +364,63 @@ async def list_memory_projects(
# Fetch cloud projects when credentials are available
cloud_list: ProjectList | None = None
cloud_entries: tuple[WorkspaceProjectEntry, ...] = ()
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
cloud_ws_tenant_id: str | None = None
cloud_ws_slug: str | None = None
cloud_ws_is_default = False
config = ConfigManager().config
if has_cloud_credentials(config):
# Use explicit workspace, fall back to config default
effective_workspace = workspace or config.default_workspace
cloud_list = await _fetch_cloud_projects(effective_workspace, context)
# Resolve workspace metadata so each cloud project carries its workspace info
if cloud_list:
cloud_ws_tenant_id = effective_workspace
if workspace:
try:
from basic_memory.mcp.project_context import get_available_workspaces
workspaces = await get_available_workspaces(context)
matched = next(
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
None,
active_workspace = await resolve_workspace_parameter(workspace, context)
except Exception as exc:
logger.warning(
f"Cloud workspace discovery failed while listing projects for "
f"workspace '{workspace}'; trying direct workspace routing before "
f"falling back to local-only project list: {exc}"
)
if matched:
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
except Exception:
pass # workspace lookup is best-effort
if context: # pragma: no cover
await context.info(
"Cloud workspace discovery failed while listing projects; "
"trying direct workspace routing"
)
cloud_list = await _fetch_cloud_projects(workspace, context)
else:
cloud_list = await _fetch_cloud_projects(active_workspace.tenant_id, context)
cloud_ws_name = active_workspace.name
cloud_ws_type = active_workspace.workspace_type
cloud_ws_tenant_id = active_workspace.tenant_id
cloud_ws_slug = active_workspace.slug
cloud_ws_is_default = active_workspace.is_default
else:
try:
workspace_index = await ensure_workspace_project_index(context=context)
cloud_entries = workspace_index.entries
except Exception as exc:
logger.warning(
f"Cloud workspace project index discovery failed while listing projects; "
f"showing local-only project list: {exc}"
)
if context: # pragma: no cover
await context.info(
"Cloud workspace project discovery failed while listing projects; "
"showing local projects only"
)
merged = _merge_projects(
local_list,
cloud_list,
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
)
if cloud_entries:
merged = _merge_workspace_projects(local_list, cloud_entries)
else:
merged = _merge_projects(
local_list,
cloud_list,
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
cloud_workspace_slug=cloud_ws_slug,
cloud_workspace_is_default=cloud_ws_is_default,
)
default_project = local_list.default_project
if output_format == "json":
@@ -390,6 +530,9 @@ async def create_memory_project(
)
status_response = await project_client.create_project(project_request.model_dump())
from basic_memory.mcp.project_context import invalidate_workspace_project_index
await invalidate_workspace_project_index(context)
if output_format == "json":
new_project = status_response.new_project
@@ -479,6 +622,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
# Delete project using project external_id
status_response = await project_client.delete_project(target_project.external_id)
from basic_memory.mcp.project_context import invalidate_workspace_project_index
await invalidate_workspace_project_index(context)
result = f"{status_response.message}\n\n"
-1
View File
@@ -5,7 +5,6 @@ to the Basic Memory API, with improved error handling and logging.
"""
import typing
from contextlib import contextmanager
from typing import Any, Optional
import logfire
+43 -29
View File
@@ -6,6 +6,42 @@ from fastmcp import Context
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.mcp.server import mcp
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
def _personal_workspace() -> WorkspaceInfo:
"""Return a display-only personal workspace when discovery has no rows.
This keeps list_workspaces friendly for non-teams or local-only users. It is not
the cloud routing source of truth; project-scoped routing still depends on real
workspace discovery and the workspace project index in project_context.
"""
return WorkspaceInfo(
tenant_id="personal",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
has_active_subscription=True,
)
def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListResponse:
"""Build the structured MCP response from the shared cloud workspace schema."""
if not workspaces:
workspaces = [_personal_workspace()]
default_workspace_id = next(
(workspace.tenant_id for workspace in workspaces if workspace.is_default),
None,
)
return WorkspaceListResponse(
workspaces=workspaces,
count=len(workspaces),
default_workspace_id=default_workspace_id,
current_workspace_id=None,
)
@mcp.tool(
@@ -24,40 +60,18 @@ async def list_workspaces(
context: Optional FastMCP context for progress/status logging.
"""
workspaces = await get_available_workspaces(context=context)
response = _workspace_list_response(workspaces)
if output_format == "json":
return {
"workspaces": [
{
"tenant_id": ws.tenant_id,
"name": ws.name,
"workspace_type": ws.workspace_type,
"role": ws.role,
"organization_id": ws.organization_id,
"has_active_subscription": ws.has_active_subscription,
}
for ws in workspaces
],
"count": len(workspaces),
}
return response.model_dump(mode="json")
if not workspaces:
return (
"# No Workspaces Available\n\n"
"No accessible workspaces were found for this account. "
"Ensure the account has an active subscription and tenant access."
)
lines = [
f"# Available Workspaces ({len(workspaces)})",
"",
"Use `workspace` as either the `tenant_id` or unique `name` in project-scoped tool calls.",
"",
]
for workspace in workspaces:
lines = [f"# Available Workspaces ({response.count})", ""]
for workspace in response.workspaces:
default_label = ", default" if workspace.is_default else ""
lines.append(
f"- {workspace.name} "
f"(type={workspace.workspace_type}, role={workspace.role}, tenant_id={workspace.tenant_id})"
f"(slug={workspace.slug}, type={workspace.workspace_type}, "
f"role={workspace.role}{default_label}, tenant_id={workspace.tenant_id})"
)
return "\n".join(lines)
+5
View File
@@ -63,8 +63,10 @@ class WorkspaceInfo(BaseModel):
tenant_id: str = Field(..., description="Workspace tenant identifier")
workspace_type: str = Field(..., description="Workspace type (personal or organization)")
slug: str = Field(..., description="Stable workspace slug for qualified project routing")
name: str = Field(..., description="Workspace display name")
role: str = Field(..., description="Current user's role in the workspace")
is_default: bool = Field(..., description="Whether this is the default cloud workspace")
organization_id: str | None = Field(None, description="Organization ID for org workspaces")
has_active_subscription: bool = Field(
default=False, description="Whether the workspace has an active subscription"
@@ -78,6 +80,9 @@ class WorkspaceListResponse(BaseModel):
default_factory=list, description="Available workspaces"
)
count: int = Field(default=0, description="Number of available workspaces")
default_workspace_id: str | None = Field(
default=None, description="Default workspace tenant ID when available"
)
current_workspace_id: str | None = Field(
default=None, description="Current workspace tenant ID when available"
)