mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix(mcp): route mixed local/cloud projects correctly (#837)
Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
@@ -142,6 +142,15 @@ async def _set_cached_active_project(
|
||||
await context.set_state("default_project_name", active_project.name)
|
||||
|
||||
|
||||
async def _clear_cached_active_project(context: Optional[Context]) -> None:
|
||||
"""Clear cached project metadata that may no longer match the active route."""
|
||||
if not context:
|
||||
return
|
||||
|
||||
await context.set_state("active_project", None)
|
||||
await context.set_state("default_project_name", None)
|
||||
|
||||
|
||||
async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
|
||||
"""Return the cached active workspace from context when available."""
|
||||
if not context:
|
||||
@@ -167,8 +176,7 @@ async def _set_cached_active_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 _clear_cached_active_project(context)
|
||||
|
||||
await context.set_state("active_workspace", active_workspace.model_dump())
|
||||
|
||||
@@ -525,6 +533,28 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _workspace_identifier_discovery_available(
|
||||
identifier: str,
|
||||
config: BasicMemoryConfig,
|
||||
) -> bool:
|
||||
"""Return True when an identifier is allowed to consult workspace discovery."""
|
||||
if _cloud_workspace_discovery_available(config):
|
||||
return True
|
||||
|
||||
from basic_memory.mcp.async_client import (
|
||||
_explicit_routing,
|
||||
_force_local_mode,
|
||||
)
|
||||
|
||||
if _explicit_routing() and _force_local_mode():
|
||||
return False
|
||||
|
||||
return (
|
||||
has_cloud_credentials(config)
|
||||
and _split_workspace_identifier_segments(identifier) is not None
|
||||
)
|
||||
|
||||
|
||||
async def resolve_workspace_qualified_memory_url(
|
||||
identifier: str,
|
||||
context: Optional[Context] = None,
|
||||
@@ -1326,11 +1356,23 @@ async def detect_project_from_identifier_prefix(
|
||||
# Outcome: keep unqualified search/title input on the active/default project route.
|
||||
return None
|
||||
|
||||
if _cloud_workspace_discovery_available(config):
|
||||
workspace_resolution = await resolve_workspace_qualified_identifier(
|
||||
identifier,
|
||||
context=context,
|
||||
if _workspace_identifier_discovery_available(identifier, config):
|
||||
workspace_discovery_fallback_errors = (
|
||||
"not found",
|
||||
"no accessible workspaces",
|
||||
"unable to discover",
|
||||
)
|
||||
try:
|
||||
workspace_resolution = await resolve_workspace_qualified_identifier(
|
||||
identifier,
|
||||
context=context,
|
||||
)
|
||||
except ValueError as exc:
|
||||
message = str(exc).lower()
|
||||
if any(error in message for error in workspace_discovery_fallback_errors):
|
||||
return None
|
||||
raise
|
||||
|
||||
if workspace_resolution is not None:
|
||||
return workspace_resolution.project_identifier
|
||||
|
||||
@@ -1345,7 +1387,7 @@ async def detect_project_from_identifier_prefix(
|
||||
)
|
||||
except ValueError as exc:
|
||||
message = str(exc).lower()
|
||||
if "not found" in message or "no accessible workspaces" in message:
|
||||
if any(error in message for error in workspace_discovery_fallback_errors):
|
||||
return None
|
||||
raise
|
||||
|
||||
@@ -1462,9 +1504,54 @@ async def get_project_client(
|
||||
if project_id and not cloud_available:
|
||||
project_mode = ProjectMode.LOCAL
|
||||
|
||||
# Trigger: project_id is a local external_id in a mixed local+cloud setup.
|
||||
# Why: UUIDs are not local config keys, so get_project_mode() treats them as
|
||||
# cloud projects. A local-first probe avoids making local UUIDs depend on
|
||||
# healthy cloud workspace discovery.
|
||||
# Outcome: resolve the effective UUID against local ASGI first; if it is not
|
||||
# local, preserve the existing cloud workspace lookup path.
|
||||
if (
|
||||
project_id
|
||||
and config.projects
|
||||
and not factory_mode
|
||||
and not explicit_cloud_routing
|
||||
and project_mode == ProjectMode.CLOUD
|
||||
):
|
||||
try:
|
||||
canonical_project_id = str(UUID(resolved_project))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
with logfire.span(
|
||||
"routing.local_project_id_probe",
|
||||
project_id=canonical_project_id,
|
||||
):
|
||||
async with get_client() as client:
|
||||
try:
|
||||
active_project = await get_active_project(
|
||||
client,
|
||||
canonical_project_id,
|
||||
context,
|
||||
)
|
||||
except ToolError as exc:
|
||||
if "not found" not in str(exc).lower():
|
||||
raise
|
||||
else:
|
||||
route_mode = "local_asgi"
|
||||
await _clear_cached_active_workspace_for_local_route(context)
|
||||
with logfire.span(
|
||||
"routing.client_session",
|
||||
project_name=active_project.name,
|
||||
route_mode=route_mode,
|
||||
):
|
||||
logger.debug("Using local ASGI routing for project_id")
|
||||
yield client, active_project
|
||||
return
|
||||
|
||||
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
|
||||
resolved_entry: WorkspaceProjectEntry | None = None
|
||||
workspace_id: str
|
||||
project_for_api = _unqualified_project_identifier(resolved_project)
|
||||
|
||||
@@ -1487,6 +1574,13 @@ async def get_project_client(
|
||||
|
||||
if active_ws is not None:
|
||||
await _set_cached_active_workspace(context, active_ws)
|
||||
if resolved_entry is not None:
|
||||
cached_project = await _get_cached_active_project(context)
|
||||
if (
|
||||
cached_project is not None
|
||||
and cached_project.external_id != resolved_entry.project.external_id
|
||||
):
|
||||
await _clear_cached_active_project(context)
|
||||
with logfire.span(
|
||||
"routing.client_session",
|
||||
project_name=project_for_api,
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
_cloud_workspace_discovery_available,
|
||||
_workspace_identifier_discovery_available,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
add_project_metadata,
|
||||
@@ -368,8 +368,9 @@ async def edit_note(
|
||||
config,
|
||||
context=context,
|
||||
)
|
||||
elif _cloud_workspace_discovery_available(
|
||||
config
|
||||
elif _workspace_identifier_discovery_available(
|
||||
identifier,
|
||||
config,
|
||||
) and is_workspace_qualified_plain_identifier(identifier):
|
||||
detected = await detect_project_from_workspace_identifier_prefix(
|
||||
identifier,
|
||||
|
||||
@@ -40,17 +40,29 @@ async def detect_project_from_workspace_identifier_prefix(
|
||||
return None
|
||||
|
||||
from basic_memory.mcp.project_context import (
|
||||
_cloud_workspace_discovery_available,
|
||||
_workspace_identifier_discovery_available,
|
||||
resolve_workspace_qualified_identifier,
|
||||
)
|
||||
|
||||
if not _cloud_workspace_discovery_available(config):
|
||||
if not _workspace_identifier_discovery_available(identifier, config):
|
||||
return None
|
||||
|
||||
workspace_resolution = await resolve_workspace_qualified_identifier(
|
||||
identifier,
|
||||
context=context,
|
||||
workspace_discovery_fallback_errors = (
|
||||
"not found",
|
||||
"no accessible workspaces",
|
||||
"unable to discover",
|
||||
)
|
||||
try:
|
||||
workspace_resolution = await resolve_workspace_qualified_identifier(
|
||||
identifier,
|
||||
context=context,
|
||||
)
|
||||
except ValueError as exc:
|
||||
message = str(exc).lower()
|
||||
if any(error in message for error in workspace_discovery_fallback_errors):
|
||||
return None
|
||||
raise
|
||||
|
||||
if workspace_resolution is None:
|
||||
return None
|
||||
return workspace_resolution.project_identifier
|
||||
|
||||
Reference in New Issue
Block a user