Files
basicmachines-co-basic-memory/docs/SPEC-PER-PROJECT-ROUTING.md
Paul Hernandez d84708ca7f feat: add per-project local/cloud routing with API key auth (#555)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:52:50 -06:00

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:

  1. User creates API key in cloud web app (already supported)
  2. Copies the key
  3. Runs bm cloud set-key bmc_abc123... → saves to config.json

Option B — Create key via CLI:

  1. User is already logged in via OAuth (bm cloud login)
  2. Runs bm cloud create-key "my-laptop" → calls POST /api/keys with JWT auth → gets key back → saves to config.json
  3. 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 ProjectMode enum: LOCAL = "local", CLOUD = "cloud"
  • Add ProjectConfigEntry Pydantic model: path: str, mode: ProjectMode = LOCAL
  • Evolve BasicMemoryConfig.projects from Dict[str, str] to Dict[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] = None field to BasicMemoryConfig (account-level, not per-project)
  • Update ProjectConfig dataclass to carry mode from config entry
  • Add helpers: get_project_entry(name), get_project_mode(name)
  • Keep global cloud_mode as deprecated fallback
  • Update all code that reads config.projects as Dict[str, str] to handle ProjectConfigEntry

Step 2: Client routing

File: src/basic_memory/mcp/async_client.py

  • Add optional project_name: Optional[str] = None parameter to get_client()
  • Routing logic (priority order):
    1. Factory injection (_client_factory) — unchanged
    2. Force-local (_force_local_mode()) — unchanged
    3. New: If project_name provided and project's mode is CLOUD → HTTP client with cloud_api_key as Bearer token, hitting cloud_host/proxy
    4. Global cloud_mode_enabled fallback — existing OAuth flow (deprecated)
    5. Default: local ASGI transport
  • Error if cloud project but no cloud_api_key in config — actionable message pointing to bm cloud set-key or bm 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_mode parameter — routing mode is orthogonal to project resolution
  • Resolution becomes purely: constrained env var → explicit param → default project
  • Update resolve_project_parameter() in project_context.py to drop cloud_mode param

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.json
  • bm cloud create-key <name> — calls POST {cloud_host}/api/keys using existing JWT auth (from make_api_request), saves returned key to config. Uses existing api_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 info to show mode column

Step 8: RuntimeMode simplification

File: src/basic_memory/runtime.py

  • resolve_runtime_mode() drops cloud_mode_enabled parameter
  • Simplifies to: TEST if test env, otherwise LOCAL
  • RuntimeMode.CLOUD kept 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 → error
  • get_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

  1. just fast-check — lint/format/typecheck + impacted tests
  2. just test — full suite (SQLite + Postgres)
  3. Manual: bm cloud set-key bmc_..., bm project set-cloud test, run MCP tools against it
  4. Manual: verify local projects work unchanged
  5. Manual: bm project list shows mode column