Skip to content

Commit 456ed59

Browse files
committed
fix(mcp): clear stale workspace for local routing
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a961aa2 commit 456ed59

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 13 additions & 0 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:
@@ -1369,6 +1380,7 @@ async def get_project_client(
13691380
# Outcome: route strictly based on explicit flag, no workspace network calls
13701381
if _explicit_routing() and _force_local_mode():
13711382
route_mode = "explicit_local"
1383+
await _clear_cached_active_workspace_for_local_route(context)
13721384
with logfire.span(
13731385
"routing.client_session",
13741386
project_name=resolved_project,
@@ -1441,6 +1453,7 @@ async def get_project_client(
14411453

14421454
# Step 4: Local routing (default)
14431455
route_mode = "local_asgi"
1456+
await _clear_cached_active_workspace_for_local_route(context)
14441457
with logfire.span(
14451458
"routing.client_session",
14461459
project_name=resolved_project,

tests/mcp/test_project_context.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,60 @@ async def test_resolve_project_and_path_preserves_existing_project_prefixed_memo
14531453
assert is_memory_url is True
14541454

14551455

1456+
@pytest.mark.asyncio
1457+
async def test_resolve_project_and_path_uses_cached_workspace_for_active_route(
1458+
config_manager,
1459+
monkeypatch,
1460+
):
1461+
from mcp.server.fastmcp.exceptions import ToolError
1462+
1463+
import basic_memory.mcp.project_context as project_context
1464+
from basic_memory.mcp.project_context import resolve_project_and_path
1465+
from basic_memory.schemas.project_info import ProjectItem
1466+
1467+
config = config_manager.load_config()
1468+
config.permalinks_include_project = True
1469+
config_manager.save_config(config)
1470+
1471+
context = _ContextState()
1472+
cached_project = ProjectItem(
1473+
id=1,
1474+
external_id="11111111-1111-1111-1111-111111111111",
1475+
name="main",
1476+
path="/tmp/main",
1477+
is_default=False,
1478+
)
1479+
team_workspace = _workspace(
1480+
tenant_id="team-tenant",
1481+
workspace_type="organization",
1482+
slug="team-paul",
1483+
name="Team Paul",
1484+
role="editor",
1485+
)
1486+
await context.set_state("active_project", cached_project.model_dump())
1487+
await context.set_state("active_workspace", team_workspace.model_dump())
1488+
1489+
async def fake_call_post(*args, **kwargs):
1490+
raise ToolError("project not found")
1491+
1492+
async def fake_get_active_project(*args, **kwargs):
1493+
return cached_project
1494+
1495+
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
1496+
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
1497+
1498+
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
1499+
client=cast(Any, None),
1500+
identifier="memory://notes/foo",
1501+
project="main",
1502+
context=_ctx(context),
1503+
)
1504+
1505+
assert active_project == cached_project
1506+
assert resolved_path == "team-paul/main/notes/foo"
1507+
assert is_memory_url is True
1508+
1509+
14561510
class TestDetectProjectFromUrlPrefix:
14571511
"""Test detect_project_from_url_prefix for URL-based project detection."""
14581512

@@ -1556,6 +1610,92 @@ async def test_local_flag_skips_workspace_resolution(self, config_manager, monke
15561610
# The error should NOT be about workspaces
15571611
assert "workspace" not in str(exc_info.value).lower()
15581612

1613+
@pytest.mark.asyncio
1614+
async def test_local_route_clears_stale_cached_workspace(self, config_manager, monkeypatch):
1615+
"""A previous cloud workspace must not decorate later local memory URLs."""
1616+
from contextlib import asynccontextmanager
1617+
1618+
from mcp.server.fastmcp.exceptions import ToolError
1619+
1620+
import basic_memory.mcp.project_context as project_context
1621+
from basic_memory.config import ProjectEntry
1622+
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
1623+
from basic_memory.schemas.project_info import ProjectItem
1624+
from basic_memory.workspace_context import current_workspace_permalink_context
1625+
1626+
config = config_manager.load_config()
1627+
config.permalinks_include_project = True
1628+
config.projects["local-proj"] = ProjectEntry(
1629+
path=str(config_manager.config_dir.parent / "local-proj")
1630+
)
1631+
config_manager.save_config(config)
1632+
1633+
context = _ContextState()
1634+
stale_workspace = _workspace(
1635+
tenant_id="team-tenant",
1636+
workspace_type="organization",
1637+
slug="team-paul",
1638+
name="Team Paul",
1639+
role="editor",
1640+
)
1641+
await context.set_state("active_workspace", stale_workspace.model_dump())
1642+
1643+
seen: dict[str, object] = {}
1644+
active = ProjectItem(
1645+
id=1,
1646+
external_id="local-project-id",
1647+
name="local-proj",
1648+
path="/local-proj",
1649+
is_default=False,
1650+
)
1651+
1652+
async def fail_ensure_workspace_project_index(context=None): # pragma: no cover
1653+
raise AssertionError("Local routing must not discover cloud workspaces")
1654+
1655+
@asynccontextmanager
1656+
async def fake_get_client(project_name=None, workspace=None):
1657+
seen["project_name"] = project_name
1658+
seen["workspace"] = workspace
1659+
seen["permalink_context"] = current_workspace_permalink_context()
1660+
yield object()
1661+
1662+
async def fake_get_active_project(client, project_name, context=None, headers=None):
1663+
assert project_name == "local-proj"
1664+
return active
1665+
1666+
async def fake_call_post(*args, **kwargs):
1667+
raise ToolError("project not found")
1668+
1669+
monkeypatch.setattr(
1670+
project_context,
1671+
"_ensure_workspace_project_index",
1672+
fail_ensure_workspace_project_index,
1673+
)
1674+
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
1675+
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
1676+
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fake_call_post)
1677+
1678+
async with get_project_client(
1679+
project="local-proj",
1680+
context=_ctx(context),
1681+
) as (client, active_project):
1682+
_, resolved_path, is_memory_url = await resolve_project_and_path(
1683+
client=cast(Any, client),
1684+
identifier="memory://notes/foo",
1685+
project="local-proj",
1686+
context=_ctx(context),
1687+
)
1688+
1689+
assert active_project == active
1690+
assert seen == {
1691+
"project_name": "local-proj",
1692+
"workspace": None,
1693+
"permalink_context": None,
1694+
}
1695+
assert resolved_path == "local-proj/notes/foo"
1696+
assert is_memory_url is True
1697+
assert await context.get_state("active_workspace") is None
1698+
15591699
@pytest.mark.asyncio
15601700
async def test_cloud_project_uses_per_project_workspace_id(self, config_manager, monkeypatch):
15611701
"""Cloud project with workspace_id uses cached workspace permalink context."""

0 commit comments

Comments
 (0)