Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
7.8 KiB
Per-Project Local/Cloud Routing
Context
basic-memory's cloud/local mode is currently a global toggle (cloud_mode: bool). When enabled, ALL projects route through the cloud proxy via OAuth. This is too coarse — users should be able to keep some projects local and route others through cloud.
The cloud API already supports API key auth (bmc_-prefixed keys, POST /api/keys to create, HybridTokenVerifier routes them automatically). API keys are per-tenant (account-level), not per-project — there are no per-project permissions in the cloud yet.
Goal: Users can set each project to local or cloud mode. Local projects use the existing ASGI in-process transport. Cloud projects use the cloud API with a single account-level API key. No OAuth dance needed for cloud project access.
UX Flow
Option A — Create key in web app:
- User creates API key in cloud web app (already supported)
- Copies the key
- Runs
bm cloud set-key bmc_abc123...→ saves to config.json
Option B — Create key via CLI:
- User is already logged in via OAuth (
bm cloud login) - Runs
bm cloud create-key "my-laptop"→ callsPOST /api/keyswith JWT auth → gets key back → saves to config.json - OAuth login is no longer needed for day-to-day use — the API key handles auth
Setting project mode:
bm project set-cloud research # route "research" project through cloud
bm project set-local research # revert to local
bm project list # shows mode column (local/cloud)
Implementation Plan
Step 1: Config model changes
File: src/basic_memory/config.py
- Add
ProjectModeenum:LOCAL = "local",CLOUD = "cloud" - Add
ProjectConfigEntryPydantic model:path: str,mode: ProjectMode = LOCAL - Evolve
BasicMemoryConfig.projectsfromDict[str, str]toDict[str, ProjectConfigEntry] - Add
model_validator(mode="before")to auto-migrate old{"name": "/path"}format to{"name": {"path": "/path", "mode": "local"}} - Add
cloud_api_key: Optional[str] = Nonefield toBasicMemoryConfig(account-level, not per-project) - Update
ProjectConfigdataclass to carrymodefrom config entry - Add helpers:
get_project_entry(name),get_project_mode(name) - Keep global
cloud_modeas deprecated fallback - Update all code that reads
config.projectsasDict[str, str]to handleProjectConfigEntry
Step 2: Client routing
File: src/basic_memory/mcp/async_client.py
- Add optional
project_name: Optional[str] = Noneparameter toget_client() - Routing logic (priority order):
- Factory injection (
_client_factory) — unchanged - Force-local (
_force_local_mode()) — unchanged - New: If
project_nameprovided and project's mode isCLOUD→ HTTP client withcloud_api_keyas Bearer token, hittingcloud_host/proxy - Global
cloud_mode_enabledfallback — existing OAuth flow (deprecated) - Default: local ASGI transport
- Factory injection (
- Error if cloud project but no
cloud_api_keyin config — actionable message pointing tobm cloud set-keyorbm cloud create-key
Step 3: Project-aware client helper
File: src/basic_memory/mcp/project_context.py
- Add
get_project_client(project, context)async context manager - Combines
resolve_project_parameter()(config-only, no network) +get_client(project_name=resolved)+get_active_project(client, resolved, context) - Returns
(client, active_project)tuple - Solves bootstrap problem: resolve project name first, create correct client, then validate
Step 4: Simplify ProjectResolver
File: src/basic_memory/project_resolver.py
- Remove global
cloud_modeparameter — routing mode is orthogonal to project resolution - Resolution becomes purely: constrained env var → explicit param → default project
- Update
resolve_project_parameter()inproject_context.pyto dropcloud_modeparam
Step 5: Update MCP tools
Files: src/basic_memory/mcp/tools/*.py (~15 files)
Mechanical change per tool:
# Before
async with get_client() as client:
active_project = await get_active_project(client, project, context)
# After
async with get_project_client(project, context) as (client, active_project):
Special handling for recent_activity.py discovery mode: iterate projects, create per-project client for each.
Step 6: Sync coordinator
Files: src/basic_memory/sync/coordinator.py, src/basic_memory/mcp/container.py
- Filter file watchers to local-mode projects only
- Cloud projects skip sync
Step 7: CLI commands
File: src/basic_memory/cli/commands/cloud/core_commands.py
bm cloud set-key <api-key>— saves API key to config.jsonbm cloud create-key <name>— callsPOST {cloud_host}/api/keysusing existing JWT auth (frommake_api_request), saves returned key to config. Uses existingapi_client.py:make_api_request()for the authenticated call.
File: src/basic_memory/cli/commands/project.py
bm project set-cloud <name>— sets project mode to cloud (validates API key exists in config)bm project set-local <name>— reverts project to local mode- Extend
bm project list/bm project infoto show mode column
Step 8: RuntimeMode simplification
File: src/basic_memory/runtime.py
resolve_runtime_mode()dropscloud_mode_enabledparameter- Simplifies to: TEST if test env, otherwise LOCAL
RuntimeMode.CLOUDkept for backward compat but not used in global resolution
Step 9: Tests
- Config: migration from old format, round-trip serialization,
get_project_mode() get_client(): local project → ASGI, cloud project → HTTP+API key, missing key → errorget_project_client(): resolve + route combined- MCP tools: representative sample with new helper
- Sync: cloud projects skipped, local projects synced
- CLI:
set-key,create-key,set-cloud,set-local
Key Files
| File | Change |
|---|---|
src/basic_memory/config.py |
ProjectMode, ProjectConfigEntry, migration, cloud_api_key field |
src/basic_memory/mcp/async_client.py |
get_client(project_name=) per-project routing |
src/basic_memory/mcp/project_context.py |
get_project_client() helper |
src/basic_memory/project_resolver.py |
Remove global cloud_mode concern |
src/basic_memory/mcp/tools/*.py |
Mechanical swap to get_project_client() |
src/basic_memory/sync/coordinator.py |
Filter to local-mode projects |
src/basic_memory/mcp/container.py |
Update should_sync logic |
src/basic_memory/cli/commands/cloud/core_commands.py |
set-key, create-key commands |
src/basic_memory/cli/commands/project.py |
set-cloud, set-local commands |
src/basic_memory/runtime.py |
Drop cloud_mode from global resolution |
Config Example
{
"projects": {
"personal": {"path": "/Users/me/notes", "mode": "local"},
"research": {"path": "/Users/me/research", "mode": "cloud"}
},
"cloud_api_key": "bmc_abc123...",
"cloud_host": "https://cloud.basicmemory.com",
"default_project": "personal"
}
Edge Cases
| Case | Handling |
|---|---|
| No API key + cloud project | get_client() raises error: "Run bm cloud set-key first" |
| Old config format loaded | model_validator auto-migrates Dict[str,str] to new format |
| Default project is cloud | Works — resolver returns name, routing uses API key |
Global cloud_mode=true (legacy) |
Deprecated fallback still works via OAuth |
| Factory-injected client (cloud app) | Factory takes priority, unaffected |
--local CLI flag on cloud project |
Force-local override still works |
Verification
just fast-check— lint/format/typecheck + impacted testsjust test— full suite (SQLite + Postgres)- Manual:
bm cloud set-key bmc_...,bm project set-cloud test, run MCP tools against it - Manual: verify local projects work unchanged
- Manual:
bm project listshows mode column