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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user