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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ Run `just test-smoke` when you specifically need the MCP smoke flow.

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

### PR CI Gate

Before opening or updating a PR, run the checks that mirror the common required CI failures:

- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
- 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.
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`.

### Test Structure

- `tests/` - Unit tests for individual components (mocked, fast)
Expand Down
80 changes: 73 additions & 7 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ async def _set_cached_active_project(
await context.set_state("default_project_name", active_project.name)


async def _clear_cached_active_project(context: Optional[Context]) -> None:
"""Clear cached project metadata that may no longer match the active route."""
if not context:
return

await context.set_state("active_project", None)
await context.set_state("default_project_name", None)


async def _get_cached_active_workspace(context: Optional[Context]) -> Optional[WorkspaceInfo]:
"""Return the cached active workspace from context when available."""
if not context:
Expand All @@ -167,8 +176,7 @@ async def _set_cached_active_workspace(
# Why: project names are only unique inside one workspace, so a cached
# ProjectItem from the previous tenant can point at the wrong project
# Outcome: force the next validation call to resolve within the new tenant
await context.set_state("active_project", None)
await context.set_state("default_project_name", None)
await _clear_cached_active_project(context)

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

Expand Down Expand Up @@ -525,6 +533,28 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
)


def _workspace_identifier_discovery_available(
identifier: str,
config: BasicMemoryConfig,
) -> bool:
"""Return True when an identifier is allowed to consult workspace discovery."""
if _cloud_workspace_discovery_available(config):
return True

from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
)

if _explicit_routing() and _force_local_mode():
return False

return (
has_cloud_credentials(config)
and _split_workspace_identifier_segments(identifier) is not None
)


async def resolve_workspace_qualified_memory_url(
identifier: str,
context: Optional[Context] = None,
Expand Down Expand Up @@ -1326,11 +1356,23 @@ async def detect_project_from_identifier_prefix(
# Outcome: keep unqualified search/title input on the active/default project route.
return None

if _cloud_workspace_discovery_available(config):
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
if _workspace_identifier_discovery_available(identifier, config):
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise

if workspace_resolution is not None:
return workspace_resolution.project_identifier

Expand All @@ -1345,7 +1387,7 @@ async def detect_project_from_identifier_prefix(
)
except ValueError as exc:
message = str(exc).lower()
if "not found" in message or "no accessible workspaces" in message:
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise

Expand Down Expand Up @@ -1462,9 +1504,26 @@ async def get_project_client(
if project_id and not cloud_available:
project_mode = ProjectMode.LOCAL

if (
project_id
and config.projects
and not factory_mode
and not explicit_cloud_routing
and project_mode == ProjectMode.CLOUD
):
try:
canonical_project_id = str(UUID(project_id))
except ValueError:
pass
else:
workspace_index = await _ensure_workspace_project_index(context=context)
if canonical_project_id not in workspace_index.entries_by_external_id:
project_mode = ProjectMode.LOCAL

if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
route_mode = "factory" if factory_mode else "cloud_proxy"
active_ws: WorkspaceInfo | None = None
resolved_entry: WorkspaceProjectEntry | None = None
workspace_id: str
project_for_api = _unqualified_project_identifier(resolved_project)

Expand All @@ -1487,6 +1546,13 @@ async def get_project_client(

if active_ws is not None:
await _set_cached_active_workspace(context, active_ws)
if resolved_entry is not None:
cached_project = await _get_cached_active_project(context)
if (
cached_project is not None
and cached_project.external_id != resolved_entry.project.external_id
):
await _clear_cached_active_project(context)
with logfire.span(
"routing.client_session",
project_name=project_for_api,
Expand Down
7 changes: 4 additions & 3 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
_cloud_workspace_discovery_available,
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
get_project_client,
add_project_metadata,
Expand Down Expand Up @@ -368,8 +368,9 @@ async def edit_note(
config,
context=context,
)
elif _cloud_workspace_discovery_available(
config
elif _workspace_identifier_discovery_available(
identifier,
config,
) and is_workspace_qualified_plain_identifier(identifier):
detected = await detect_project_from_workspace_identifier_prefix(
identifier,
Expand Down
22 changes: 17 additions & 5 deletions src/basic_memory/services/link_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,29 @@ async def detect_project_from_workspace_identifier_prefix(
return None

from basic_memory.mcp.project_context import (
_cloud_workspace_discovery_available,
_workspace_identifier_discovery_available,
resolve_workspace_qualified_identifier,
)

if not _cloud_workspace_discovery_available(config):
if not _workspace_identifier_discovery_available(identifier, config):
return None

workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
workspace_discovery_fallback_errors = (
"not found",
"no accessible workspaces",
"unable to discover",
)
try:
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
except ValueError as exc:
message = str(exc).lower()
if any(error in message for error in workspace_discovery_fallback_errors):
return None
raise

if workspace_resolution is None:
return None
return workspace_resolution.project_identifier
Expand Down
25 changes: 25 additions & 0 deletions tests/mcp/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ def mcp() -> FastMCP:
return cast(Any, mcp_server)


class ContextState:
"""Minimal FastMCP context-state stub for MCP tests."""

def __init__(self):
self._state: dict[str, object] = {}

async def get_state(self, key: str):
return self._state.get(key)

async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value

async def info(self, message: str) -> None:
self._state["info_message"] = message


def ctx(context: ContextState) -> Any:
return cast(Any, context)


@pytest.fixture
def context_state() -> ContextState:
return ContextState()


@pytest.fixture(scope="function")
def app(
app_config, project_config, engine_factory, config_manager
Expand Down
Loading
Loading