Skip to content

Commit 90b9bf3

Browse files
committed
fix(mcp): route mixed local cloud projects correctly
1 parent 60ec672 commit 90b9bf3

2 files changed

Lines changed: 342 additions & 4 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ async def _set_cached_active_project(
142142
await context.set_state("default_project_name", active_project.name)
143143

144144

145+
async def _clear_cached_active_project(context: Optional[Context]) -> None:
146+
"""Clear cached project metadata that may no longer match the active route."""
147+
if not context:
148+
return
149+
150+
await context.set_state("active_project", None)
151+
await context.set_state("default_project_name", None)
152+
153+
145154
async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
146155
"""Return the cached active workspace from context when available."""
147156
if not context:
@@ -167,8 +176,7 @@ async def _set_cached_active_workspace(
167176
# Why: project names are only unique inside one workspace, so a cached
168177
# ProjectItem from the previous tenant can point at the wrong project
169178
# Outcome: force the next validation call to resolve within the new tenant
170-
await context.set_state("active_project", None)
171-
await context.set_state("default_project_name", None)
179+
await _clear_cached_active_project(context)
172180

173181
await context.set_state("active_workspace", active_workspace.model_dump())
174182

@@ -525,6 +533,28 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
525533
)
526534

527535

536+
def _workspace_identifier_discovery_available(
537+
identifier: str,
538+
config: BasicMemoryConfig,
539+
) -> bool:
540+
"""Return True when an identifier is allowed to consult workspace discovery."""
541+
if _cloud_workspace_discovery_available(config):
542+
return True
543+
544+
from basic_memory.mcp.async_client import (
545+
_explicit_routing,
546+
_force_local_mode,
547+
)
548+
549+
if _explicit_routing() and _force_local_mode():
550+
return False
551+
552+
return (
553+
has_cloud_credentials(config)
554+
and _split_workspace_identifier_segments(identifier) is not None
555+
)
556+
557+
528558
async def resolve_workspace_qualified_memory_url(
529559
identifier: str,
530560
context: Optional[Context] = None,
@@ -1326,7 +1356,7 @@ async def detect_project_from_identifier_prefix(
13261356
# Outcome: keep unqualified search/title input on the active/default project route.
13271357
return None
13281358

1329-
if _cloud_workspace_discovery_available(config):
1359+
if _workspace_identifier_discovery_available(identifier, config):
13301360
workspace_resolution = await resolve_workspace_qualified_identifier(
13311361
identifier,
13321362
context=context,
@@ -1462,9 +1492,26 @@ async def get_project_client(
14621492
if project_id and not cloud_available:
14631493
project_mode = ProjectMode.LOCAL
14641494

1495+
if (
1496+
project_id
1497+
and config.projects
1498+
and not factory_mode
1499+
and not explicit_cloud_routing
1500+
and project_mode == ProjectMode.CLOUD
1501+
):
1502+
try:
1503+
canonical_project_id = str(UUID(project_id))
1504+
except ValueError:
1505+
pass
1506+
else:
1507+
workspace_index = await _ensure_workspace_project_index(context=context)
1508+
if canonical_project_id not in workspace_index.entries_by_external_id:
1509+
project_mode = ProjectMode.LOCAL
1510+
14651511
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
14661512
route_mode = "factory" if factory_mode else "cloud_proxy"
14671513
active_ws: WorkspaceInfo | None = None
1514+
resolved_entry: WorkspaceProjectEntry | None = None
14681515
workspace_id: str
14691516
project_for_api = _unqualified_project_identifier(resolved_project)
14701517

@@ -1487,6 +1534,13 @@ async def get_project_client(
14871534

14881535
if active_ws is not None:
14891536
await _set_cached_active_workspace(context, active_ws)
1537+
if resolved_entry is not None:
1538+
cached_project = await _get_cached_active_project(context)
1539+
if (
1540+
cached_project is not None
1541+
and cached_project.external_id != resolved_entry.project.external_id
1542+
):
1543+
await _clear_cached_active_project(context)
14901544
with logfire.span(
14911545
"routing.client_session",
14921546
project_name=project_for_api,

0 commit comments

Comments
 (0)