Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,45 @@ async def get_project_client(
# Outcome: in pure local mode, treat UUID identifiers as local routing; cloud
# discovery still happens when factory/explicit/credentials are present
cloud_available = factory_mode or explicit_cloud_routing or has_cloud_credentials(config)

# 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 and searches only the cloud workspace index.
# Outcome: first give the local ASGI API a chance to resolve the UUID; if it
# is not a local project, keep the existing cloud workspace lookup path.
if project_id and config.projects and not factory_mode and not explicit_cloud_routing:
try:
canonical_project_id = str(UUID(project_id))
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,
None,
)
Comment on lines +1480 to +1484
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Respect env-constrained project when probing local project_id

The new local project_id probe bypasses the previously resolved project constraint and re-validates the raw UUID via get_active_project(..., canonical_project_id, None). Because resolve_project_parameter() is supposed to honor BASIC_MEMORY_MCP_PROJECT with highest priority, this branch can route to a different local project whenever that env var is set and a caller still passes project_id, causing tools to operate on the wrong project. This is a correctness/data-isolation regression introduced by the new early-return path.

Useful? React with 👍 / 👎.

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)
await _set_cached_active_project(context, active_project)
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 project_id and not cloud_available:
project_mode = ProjectMode.LOCAL

Expand Down
51 changes: 51 additions & 0 deletions tests/mcp/test_project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,57 @@ async def fake_get_active_project(client, project, context, headers=None):
assert captured["validated_project"] == canonical_uuid


@pytest.mark.asyncio
async def test_get_project_client_with_project_id_routes_locally_with_cloud_credentials(
config_manager, monkeypatch
):
"""A local UUID project_id must not be forced through cloud workspace lookup."""
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import get_project_client

config = config_manager.load_config()
local_path = config_manager.config_dir.parent / "local-project"
local_path.mkdir(parents=True, exist_ok=True)
config.projects["local-project"] = ProjectEntry(path=str(local_path))
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)

captured: dict[str, object] = {}

@asynccontextmanager
async def fake_get_client(**kwargs) -> AsyncIterator[object]:
captured["get_client_kwargs"] = kwargs
yield object()

async def fake_get_active_project(client, project, context=None, headers=None):
captured["validated_project"] = project
return _project("Local Project", id=99, external_id=project)

async def fail_resolve_workspace_project_identifier(
project_name, context=None
): # pragma: no cover
raise AssertionError("Local project_id should route locally before cloud discovery")

monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
monkeypatch.setattr(
project_context,
"resolve_workspace_project_identifier",
fail_resolve_workspace_project_identifier,
)

canonical_uuid = "66666666-6666-6666-6666-666666666666"
async with get_project_client(project_id=canonical_uuid) as (_, active):
assert active.external_id == canonical_uuid

assert captured["get_client_kwargs"] == {}
assert captured["validated_project"] == canonical_uuid


@pytest.mark.asyncio
async def test_get_project_client_prefers_project_id_over_project_name(monkeypatch):
"""When both project and project_id are passed, the UUID takes precedence."""
Expand Down