Skip to content

Commit 7918e5c

Browse files
authored
fix(mcp): preserve workspace paths in build_context (#801)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent f312341 commit 7918e5c

3 files changed

Lines changed: 414 additions & 18 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@ async def _set_cached_active_workspace(
162162
await context.set_state("active_workspace", active_workspace.model_dump())
163163

164164

165+
async def _clear_cached_active_workspace_for_local_route(context: Optional[Context]) -> None:
166+
"""Drop tenant workspace metadata before routing through a local project."""
167+
if not context:
168+
return
169+
170+
# Trigger: local routing follows a cloud route in the same MCP session
171+
# Why: active_workspace is tenant metadata, not part of local project identity
172+
# Outcome: memory:// resolution uses project-only local permalinks
173+
await context.set_state("active_workspace", None)
174+
175+
165176
async def _get_cached_default_project(context: Optional[Context]) -> Optional[str]:
166177
"""Return the cached default project name from context when available."""
167178
if not context:
@@ -446,6 +457,52 @@ def _canonical_memory_path_for_workspace(
446457
return f"{prefix}/{normalized_remainder}"
447458

448459

460+
def _canonical_memory_path_for_active_route(
461+
active_project: ProjectItem,
462+
path: str,
463+
*,
464+
include_project: bool,
465+
cached_workspace: WorkspaceInfo | None = None,
466+
) -> str:
467+
"""Return the canonical permalink path for the currently routed project/workspace."""
468+
project_prefix = active_project.permalink
469+
workspace_remainder = path
470+
if include_project and (path == project_prefix or path.startswith(f"{project_prefix}/")):
471+
# Trigger: the memory URL already names the active project root/prefix
472+
# Why: workspace canonicalization adds the project prefix itself, so
473+
# keeping it in the remainder would produce <workspace>/<project>/<project>
474+
# Outcome: keep project-root and project-prefixed URLs canonical once
475+
workspace_remainder = (
476+
"" if path == project_prefix else path.removeprefix(f"{project_prefix}/")
477+
)
478+
479+
workspace_context = current_workspace_permalink_context()
480+
if workspace_context is not None:
481+
return _canonical_memory_path_for_workspace(
482+
workspace_slug=workspace_context.workspace_slug,
483+
workspace_type=workspace_context.workspace_type,
484+
project_permalink=active_project.permalink,
485+
remainder=workspace_remainder,
486+
include_project=include_project,
487+
)
488+
489+
if cached_workspace is not None:
490+
return _canonical_memory_path_for_workspace(
491+
workspace_slug=cached_workspace.slug,
492+
workspace_type=cached_workspace.workspace_type,
493+
project_permalink=active_project.permalink,
494+
remainder=workspace_remainder,
495+
include_project=include_project,
496+
)
497+
498+
if not include_project:
499+
return path
500+
501+
if path == project_prefix or path.startswith(f"{project_prefix}/"):
502+
return path
503+
return f"{project_prefix}/{path}"
504+
505+
449506
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
450507
"""Return True when workspace discovery can be used without forcing local routing."""
451508
from basic_memory.mcp.async_client import (
@@ -1115,8 +1172,11 @@ async def resolve_project_and_path(
11151172
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
11161173
)
11171174

1118-
resolved_path = (
1119-
f"{cached_project.permalink}/{remainder}" if include_project else remainder
1175+
resolved_path = _canonical_memory_path_for_active_route(
1176+
cached_project,
1177+
remainder,
1178+
include_project=include_project,
1179+
cached_workspace=cached_workspace,
11201180
)
11211181
return cached_project, resolved_path, True
11221182

@@ -1151,25 +1211,24 @@ async def resolve_project_and_path(
11511211
)
11521212
await _set_cached_active_project(context, active_project)
11531213

1154-
resolved_path = (
1155-
f"{resolved.permalink}/{remainder}" if include_project else remainder
1214+
resolved_path = _canonical_memory_path_for_active_route(
1215+
active_project,
1216+
remainder,
1217+
include_project=include_project,
1218+
cached_workspace=cached_workspace,
11561219
)
11571220
return active_project, resolved_path, True
11581221

1159-
# Trigger: no resolvable project prefix in the memory URL
1160-
# Why: preserve existing memory URL behavior within the active project
1161-
# Outcome: use the active project and normalize the path for lookup
1222+
# Trigger: memory URL has no resolvable project route segment
1223+
# Why: preserve active-project behavior while honoring workspace paths
1224+
# Outcome: normalize against the already-selected local/cloud route
11621225
active_project = await get_active_project(client, project, context, headers)
1163-
resolved_path = normalized_path
1164-
if include_project:
1165-
# Trigger: project-prefixed permalinks are enabled and the path lacks a prefix
1166-
# Why: ensure memory URL lookups align with canonical permalinks
1167-
# Outcome: prefix the path with the active project's permalink
1168-
project_prefix = active_project.permalink
1169-
if resolved_path != project_prefix and not resolved_path.startswith(
1170-
f"{project_prefix}/"
1171-
):
1172-
resolved_path = f"{project_prefix}/{resolved_path}"
1226+
resolved_path = _canonical_memory_path_for_active_route(
1227+
active_project,
1228+
normalized_path,
1229+
include_project=include_project,
1230+
cached_workspace=cached_workspace,
1231+
)
11731232
return active_project, resolved_path, True
11741233

11751234

@@ -1325,6 +1384,7 @@ async def get_project_client(
13251384
# Outcome: route strictly based on explicit flag, no workspace network calls
13261385
if _explicit_routing() and _force_local_mode():
13271386
route_mode = "explicit_local"
1387+
await _clear_cached_active_workspace_for_local_route(context)
13281388
with logfire.span(
13291389
"routing.client_session",
13301390
project_name=resolved_project,
@@ -1397,6 +1457,7 @@ async def get_project_client(
13971457

13981458
# Step 4: Local routing (default)
13991459
route_mode = "local_asgi"
1460+
await _clear_cached_active_workspace_for_local_route(context)
14001461
with logfire.span(
14011462
"routing.client_session",
14021463
project_name=resolved_project,

0 commit comments

Comments
 (0)