Skip to content

Commit 30499a9

Browse files
phernandezclaude
andauthored
feat: Let stdio MCP honor per-project cloud routing (#590)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ac65b9 commit 30499a9

8 files changed

Lines changed: 117 additions & 36 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,8 @@ basic-memory project ls --name main --cloud
396396

397397
No-flag behavior defaults to local when no project context is present.
398398

399-
The local MCP server (`basic-memory mcp`) always uses local routing (including `--transport stdio`).
399+
The local MCP server routes per transport: `--transport stdio` honors per-project routing
400+
(local or cloud), while `--transport streamable-http` and `--transport sse` always route locally.
400401

401402
**CLI Note Editing (`tool edit-note`):**
402403

docs/SPEC-PER-PROJECT-ROUTING.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This document is the canonical contract for local/cloud routing behavior in CLI,
1010
## Goals
1111

1212
1. Remove global `cloud_mode` from runtime/routing semantics.
13-
2. Keep MCP stdio local-only and predictable.
13+
2. Keep MCP HTTP/SSE local-only; let stdio honor per-project routing.
1414
3. Make CLI routing explicit and easy to reason about.
1515
4. Support projects that exist in both local and cloud without ambiguity.
1616

@@ -79,13 +79,25 @@ When explicit routing is active, project mode does not override the selected rou
7979
- reports auth state (API key, OAuth token validity)
8080
- runs health checks only when credentials are available
8181

82-
## MCP Stdio Local Guarantee
82+
## MCP Transport Routing
8383

84-
`bm mcp --transport stdio` always routes locally.
84+
### Stdio (default)
8585

86-
The command sets explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and
87-
`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud routing for stdio MCP,
88-
even if the selected project has `mode: cloud`.
86+
`bm mcp --transport stdio` uses natural per-project routing.
87+
88+
- Local-mode projects route through the in-process ASGI transport.
89+
- Cloud-mode projects route to the cloud proxy with Bearer auth (API key).
90+
- No explicit routing env vars are injected by the CLI command.
91+
- Externally-set env vars are honored (e.g. `BASIC_MEMORY_FORCE_CLOUD=true` for cloud deployments).
92+
- Users who need all projects forced local can set `BASIC_MEMORY_FORCE_LOCAL=true` externally.
93+
94+
### HTTP and SSE Transports
95+
96+
`bm mcp --transport streamable-http` and `bm mcp --transport sse` always route locally.
97+
98+
These transports set explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and
99+
`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud
100+
routing regardless of project mode, since HTTP/SSE serve as local API endpoints.
89101

90102
## Project List UX for Dual Presence
91103

@@ -130,6 +142,6 @@ Runtime mode is no longer a cloud/local routing switch for local app flows.
130142
3. `--local/--cloud` always override per-project mode for that command.
131143
4. No-project + no-flags commands route local by default.
132144
5. `bm cloud login/logout` do not toggle routing behavior.
133-
6. `bm mcp` remains local-only in stdio mode.
145+
6. `bm mcp` stdio routes per-project mode; HTTP/SSE remain local-forced.
134146
7. `bm project list` communicates dual local/cloud presence without ambiguity.
135147
8. `bm project ls` output identifies route target explicitly.

src/basic_memory/cli/analytics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
# Configuration — read from environment so nothing is hard-coded in source
2626
# ---------------------------------------------------------------------------
2727

28+
2829
def _umami_host() -> Optional[str]:
2930
return os.getenv("BASIC_MEMORY_UMAMI_HOST", "").strip() or None
3031

src/basic_memory/cli/commands/mcp.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,20 @@ def mcp(
4545
Users who have cloud mode enabled can still use local MCP for Claude Code
4646
and Claude Desktop while using cloud MCP for web and mobile access.
4747
"""
48-
# Force local routing for local MCP server.
49-
# Trigger: MCP server command invocation (all transports).
50-
# Why: local MCP must never route through cloud; stdio in particular must
51-
# remain local-only to avoid cross-environment ambiguity.
52-
# Outcome: explicit local override disables per-project cloud routing.
53-
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
54-
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
55-
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
48+
# --- Routing setup ---
49+
# Trigger: MCP server command invocation.
50+
# Why: HTTP/SSE transports serve as local API endpoints and must never
51+
# route through cloud. Stdio is a client-facing protocol that
52+
# should honor per-project routing (local or cloud).
53+
# Outcome: HTTP/SSE get explicit local override; stdio passes through
54+
# whatever env vars are already set (honoring external overrides)
55+
# and defaults to per-project routing resolution.
56+
if transport in ("streamable-http", "sse"):
57+
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
58+
os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None)
59+
os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true"
60+
# stdio: no env var manipulation — per-project routing applies by default,
61+
# and externally-set env vars (e.g. BASIC_MEMORY_FORCE_CLOUD) are honored.
5662

5763
# Import mcp tools/prompts to register them with the server
5864
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover

src/basic_memory/cli/promo.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
77
from rich.panel import Panel
88

99
import basic_memory
10-
from basic_memory.cli.analytics import track, EVENT_PROMO_SHOWN, EVENT_PROMO_OPTED_OUT
10+
from basic_memory.cli.analytics import track, EVENT_PROMO_SHOWN
1111
from basic_memory.config import ConfigManager
1212

1313
OSS_DISCOUNT_CODE = "BMFOSS"
1414
CLOUD_LEARN_MORE_URL = (
15-
"https://basicmemory.com"
16-
"?utm_source=bm-cli&utm_medium=promo&utm_campaign=cloud-upsell"
15+
"https://basicmemory.com?utm_source=bm-cli&utm_medium=promo&utm_campaign=cloud-upsell"
1716
)
1817

1918

src/basic_memory/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ class DatabaseBackend(str, Enum):
4242
def _default_semantic_search_enabled() -> bool:
4343
"""Enable semantic search by default when required local semantic dependencies exist."""
4444
required_modules = ("fastembed", "sqlite_vec")
45-
return all(importlib.util.find_spec(module_name) is not None for module_name in required_modules)
45+
return all(
46+
importlib.util.find_spec(module_name) is not None for module_name in required_modules
47+
)
4648

4749

4850
@dataclass

test-int/cli/test_routing_integration.py

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Integration tests for CLI routing flags (--local/--cloud).
22
33
These tests verify that the --local and --cloud flags work correctly
4-
across CLI commands, and that the MCP command forces local routing.
4+
across CLI commands, and that MCP routing varies by transport.
55
66
Note: Environment variable behavior during command execution is tested
77
in unit tests (tests/cli/test_routing.py) which can properly monkeypatch
@@ -89,33 +89,93 @@ def test_tool_edit_note_both_flags_error(self):
8989
assert "Cannot specify both --local and --cloud" in result.output
9090

9191

92-
class TestMcpCommandForcesLocal:
93-
"""Tests that the MCP command forces local routing."""
92+
class TestMcpCommandRouting:
93+
"""Tests that MCP routing varies by transport."""
9494

95-
def test_mcp_sets_force_local_env(self, monkeypatch):
96-
"""MCP command should set BASIC_MEMORY_FORCE_LOCAL before server starts."""
97-
# Track what environment variable was set
98-
env_set_value = []
95+
def test_mcp_stdio_does_not_force_local(self, monkeypatch):
96+
"""Stdio transport should not inject explicit local routing env vars."""
97+
# Ensure env is clean before test
98+
monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False)
99+
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
100+
101+
env_at_run = {}
99102

100-
# Mock the MCP server run to capture env state without actually starting server
101103
import basic_memory.cli.commands.mcp as mcp_mod
102104

103105
def mock_run(*args, **kwargs):
104-
env_set_value.append(os.environ.get("BASIC_MEMORY_FORCE_LOCAL"))
105-
# Don't actually start the server
106+
env_at_run["FORCE_LOCAL"] = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
107+
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
106108
raise SystemExit(0)
107109

108-
# Get the actual mcp_server from the module
109110
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
111+
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
112+
113+
runner.invoke(cli_app, ["mcp"]) # default transport is stdio
114+
115+
# Command should not have set these vars
116+
assert env_at_run["FORCE_LOCAL"] is None
117+
assert env_at_run["EXPLICIT"] is None
118+
119+
def test_mcp_stdio_honors_external_env_override(self, monkeypatch):
120+
"""Stdio transport should pass through externally-set routing env vars."""
121+
monkeypatch.setenv("BASIC_MEMORY_FORCE_CLOUD", "true")
122+
monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true")
110123

111-
# Also mock init_mcp_logging to avoid file operations
124+
env_at_run = {}
125+
126+
import basic_memory.cli.commands.mcp as mcp_mod
127+
128+
def mock_run(*args, **kwargs):
129+
env_at_run["FORCE_CLOUD"] = os.environ.get("BASIC_MEMORY_FORCE_CLOUD")
130+
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
131+
raise SystemExit(0)
132+
133+
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
112134
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
113135

114136
runner.invoke(cli_app, ["mcp"])
115137

116-
# Environment variable should have been set to "true"
117-
assert len(env_set_value) == 1
118-
assert env_set_value[0] == "true"
138+
# Externally-set vars should be preserved
139+
assert env_at_run["FORCE_CLOUD"] == "true"
140+
assert env_at_run["EXPLICIT"] == "true"
141+
142+
def test_mcp_streamable_http_forces_local(self, monkeypatch):
143+
"""Streamable-HTTP transport should force local routing."""
144+
env_at_run = {}
145+
146+
import basic_memory.cli.commands.mcp as mcp_mod
147+
148+
def mock_run(*args, **kwargs):
149+
env_at_run["FORCE_LOCAL"] = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
150+
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
151+
raise SystemExit(0)
152+
153+
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
154+
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
155+
156+
runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
157+
158+
assert env_at_run["FORCE_LOCAL"] == "true"
159+
assert env_at_run["EXPLICIT"] == "true"
160+
161+
def test_mcp_sse_forces_local(self, monkeypatch):
162+
"""SSE transport should force local routing."""
163+
env_at_run = {}
164+
165+
import basic_memory.cli.commands.mcp as mcp_mod
166+
167+
def mock_run(*args, **kwargs):
168+
env_at_run["FORCE_LOCAL"] = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
169+
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
170+
raise SystemExit(0)
171+
172+
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
173+
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
174+
175+
runner.invoke(cli_app, ["mcp", "--transport", "sse"])
176+
177+
assert env_at_run["FORCE_LOCAL"] == "true"
178+
assert env_at_run["EXPLICIT"] == "true"
119179

120180

121181
class TestToolCommandsAcceptFlags:

tests/cli/test_analytics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Tests for CLI analytics module."""
22

33
import json
4-
import threading
54
from unittest.mock import patch, MagicMock
65

76
import pytest
@@ -128,6 +127,7 @@ def fake_urlopen(req, timeout=None):
128127

129128
with patch("basic_memory.cli.analytics.urllib.request.urlopen", fake_urlopen):
130129
with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread:
130+
131131
def run_target(target, daemon):
132132
target() # Should not raise
133133
return MagicMock()

0 commit comments

Comments
 (0)