Skip to content

Commit 5ae8a73

Browse files
authored
fix(mcp): route mixed local/cloud projects correctly (#837)
Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent b94ef01 commit 5ae8a73

13 files changed

Lines changed: 1158 additions & 136 deletions

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ Run `just test-smoke` when you specifically need the MCP smoke flow.
5656

5757
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
5858

59+
### PR CI Gate
60+
61+
Before opening or updating a PR, run the checks that mirror the common required CI failures:
62+
63+
- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
64+
- Sign commits with `git commit -s` so DCO passes. If a PR branch already has unsigned commits, rewrite the branch with signed-off commits before asking for review.
65+
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
66+
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`.
67+
5968
### Test Structure
6069

6170
- `tests/` - Unit tests for individual components (mocked, fast)

src/basic_memory/mcp/project_context.py

Lines changed: 101 additions & 7 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,11 +1356,23 @@ 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):
1330-
workspace_resolution = await resolve_workspace_qualified_identifier(
1331-
identifier,
1332-
context=context,
1359+
if _workspace_identifier_discovery_available(identifier, config):
1360+
workspace_discovery_fallback_errors = (
1361+
"not found",
1362+
"no accessible workspaces",
1363+
"unable to discover",
13331364
)
1365+
try:
1366+
workspace_resolution = await resolve_workspace_qualified_identifier(
1367+
identifier,
1368+
context=context,
1369+
)
1370+
except ValueError as exc:
1371+
message = str(exc).lower()
1372+
if any(error in message for error in workspace_discovery_fallback_errors):
1373+
return None
1374+
raise
1375+
13341376
if workspace_resolution is not None:
13351377
return workspace_resolution.project_identifier
13361378

@@ -1345,7 +1387,7 @@ async def detect_project_from_identifier_prefix(
13451387
)
13461388
except ValueError as exc:
13471389
message = str(exc).lower()
1348-
if "not found" in message or "no accessible workspaces" in message:
1390+
if any(error in message for error in workspace_discovery_fallback_errors):
13491391
return None
13501392
raise
13511393

@@ -1462,9 +1504,54 @@ async def get_project_client(
14621504
if project_id and not cloud_available:
14631505
project_mode = ProjectMode.LOCAL
14641506

1507+
# Trigger: project_id is a local external_id in a mixed local+cloud setup.
1508+
# Why: UUIDs are not local config keys, so get_project_mode() treats them as
1509+
# cloud projects. A local-first probe avoids making local UUIDs depend on
1510+
# healthy cloud workspace discovery.
1511+
# Outcome: resolve the effective UUID against local ASGI first; if it is not
1512+
# local, preserve the existing cloud workspace lookup path.
1513+
if (
1514+
project_id
1515+
and config.projects
1516+
and not factory_mode
1517+
and not explicit_cloud_routing
1518+
and project_mode == ProjectMode.CLOUD
1519+
):
1520+
try:
1521+
canonical_project_id = str(UUID(resolved_project))
1522+
except ValueError:
1523+
pass
1524+
else:
1525+
with logfire.span(
1526+
"routing.local_project_id_probe",
1527+
project_id=canonical_project_id,
1528+
):
1529+
async with get_client() as client:
1530+
try:
1531+
active_project = await get_active_project(
1532+
client,
1533+
canonical_project_id,
1534+
context,
1535+
)
1536+
except ToolError as exc:
1537+
if "not found" not in str(exc).lower():
1538+
raise
1539+
else:
1540+
route_mode = "local_asgi"
1541+
await _clear_cached_active_workspace_for_local_route(context)
1542+
with logfire.span(
1543+
"routing.client_session",
1544+
project_name=active_project.name,
1545+
route_mode=route_mode,
1546+
):
1547+
logger.debug("Using local ASGI routing for project_id")
1548+
yield client, active_project
1549+
return
1550+
14651551
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
14661552
route_mode = "factory" if factory_mode else "cloud_proxy"
14671553
active_ws: WorkspaceInfo | None = None
1554+
resolved_entry: WorkspaceProjectEntry | None = None
14681555
workspace_id: str
14691556
project_for_api = _unqualified_project_identifier(resolved_project)
14701557

@@ -1487,6 +1574,13 @@ async def get_project_client(
14871574

14881575
if active_ws is not None:
14891576
await _set_cached_active_workspace(context, active_ws)
1577+
if resolved_entry is not None:
1578+
cached_project = await _get_cached_active_project(context)
1579+
if (
1580+
cached_project is not None
1581+
and cached_project.external_id != resolved_entry.project.external_id
1582+
):
1583+
await _clear_cached_active_project(context)
14901584
with logfire.span(
14911585
"routing.client_session",
14921586
project_name=project_for_api,

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from basic_memory.config import ConfigManager
1111
from basic_memory.mcp.project_context import (
12-
_cloud_workspace_discovery_available,
12+
_workspace_identifier_discovery_available,
1313
detect_project_from_memory_url_prefix,
1414
get_project_client,
1515
add_project_metadata,
@@ -368,8 +368,9 @@ async def edit_note(
368368
config,
369369
context=context,
370370
)
371-
elif _cloud_workspace_discovery_available(
372-
config
371+
elif _workspace_identifier_discovery_available(
372+
identifier,
373+
config,
373374
) and is_workspace_qualified_plain_identifier(identifier):
374375
detected = await detect_project_from_workspace_identifier_prefix(
375376
identifier,

src/basic_memory/services/link_resolver.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,29 @@ async def detect_project_from_workspace_identifier_prefix(
4040
return None
4141

4242
from basic_memory.mcp.project_context import (
43-
_cloud_workspace_discovery_available,
43+
_workspace_identifier_discovery_available,
4444
resolve_workspace_qualified_identifier,
4545
)
4646

47-
if not _cloud_workspace_discovery_available(config):
47+
if not _workspace_identifier_discovery_available(identifier, config):
4848
return None
4949

50-
workspace_resolution = await resolve_workspace_qualified_identifier(
51-
identifier,
52-
context=context,
50+
workspace_discovery_fallback_errors = (
51+
"not found",
52+
"no accessible workspaces",
53+
"unable to discover",
5354
)
55+
try:
56+
workspace_resolution = await resolve_workspace_qualified_identifier(
57+
identifier,
58+
context=context,
59+
)
60+
except ValueError as exc:
61+
message = str(exc).lower()
62+
if any(error in message for error in workspace_discovery_fallback_errors):
63+
return None
64+
raise
65+
5466
if workspace_resolution is None:
5567
return None
5668
return workspace_resolution.project_identifier

tests/mcp/conftest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,31 @@ def mcp() -> FastMCP:
2020
return cast(Any, mcp_server)
2121

2222

23+
class ContextState:
24+
"""Minimal FastMCP context-state stub for MCP tests."""
25+
26+
def __init__(self):
27+
self._state: dict[str, object] = {}
28+
29+
async def get_state(self, key: str):
30+
return self._state.get(key)
31+
32+
async def set_state(self, key: str, value: object, **kwargs) -> None:
33+
self._state[key] = value
34+
35+
async def info(self, message: str) -> None:
36+
self._state["info_message"] = message
37+
38+
39+
def ctx(context: ContextState) -> Any:
40+
return cast(Any, context)
41+
42+
43+
@pytest.fixture
44+
def context_state() -> ContextState:
45+
return ContextState()
46+
47+
2348
@pytest.fixture(scope="function")
2449
def app(
2550
app_config, project_config, engine_factory, config_manager

0 commit comments

Comments
 (0)