Skip to content

Commit a98e2b5

Browse files
committed
test(mcp): cover mixed workspace routing gaps
1 parent 90b9bf3 commit a98e2b5

8 files changed

Lines changed: 584 additions & 19 deletions

File tree

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from basic_memory.config import ConfigManager
1111
from basic_memory.mcp.project_context import (
12-
_cloud_workspace_discovery_available,
12+
_workspace_identifier_discovery_available,
1313
detect_project_from_memory_url_prefix,
1414
get_project_client,
1515
add_project_metadata,
@@ -368,8 +368,9 @@ async def edit_note(
368368
config,
369369
context=context,
370370
)
371-
elif _cloud_workspace_discovery_available(
372-
config
371+
elif _workspace_identifier_discovery_available(
372+
identifier,
373+
config,
373374
) and is_workspace_qualified_plain_identifier(identifier):
374375
detected = await detect_project_from_workspace_identifier_prefix(
375376
identifier,

src/basic_memory/services/link_resolver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ async def detect_project_from_workspace_identifier_prefix(
4040
return None
4141

4242
from basic_memory.mcp.project_context import (
43-
_cloud_workspace_discovery_available,
43+
_workspace_identifier_discovery_available,
4444
resolve_workspace_qualified_identifier,
4545
)
4646

47-
if not _cloud_workspace_discovery_available(config):
47+
if not _workspace_identifier_discovery_available(identifier, config):
4848
return None
4949

5050
workspace_resolution = await resolve_workspace_qualified_identifier(

tests/mcp/test_project_context.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,83 @@ async def fake_get_active_project(client, project, context=None, headers=None):
13621362
assert captured["validated_project"] == local_uuid
13631363

13641364

1365+
@pytest.mark.asyncio
1366+
async def test_get_project_client_with_local_project_id_clears_cached_workspace(
1367+
config_manager, monkeypatch
1368+
):
1369+
"""Local project_id routing must not inherit a previous cloud workspace context."""
1370+
import basic_memory.mcp.project_context as project_context
1371+
from basic_memory.config import ProjectEntry
1372+
from basic_memory.mcp.project_context import (
1373+
WorkspaceProjectEntry,
1374+
_build_workspace_project_index,
1375+
)
1376+
1377+
config = config_manager.load_config()
1378+
config.projects["hermes-memory"] = ProjectEntry(
1379+
path=str(config_manager.config_dir.parent / "hermes-memory")
1380+
)
1381+
config.cloud_api_key = "bmc_test123"
1382+
config_manager.save_config(config)
1383+
1384+
personal = _workspace(
1385+
tenant_id="personal-tenant",
1386+
workspace_type="personal",
1387+
slug="personal",
1388+
name="Personal",
1389+
role="owner",
1390+
is_default=True,
1391+
)
1392+
index = _build_workspace_project_index(
1393+
(personal,),
1394+
(
1395+
WorkspaceProjectEntry(
1396+
workspace=personal,
1397+
project=_project(
1398+
"main",
1399+
id=1,
1400+
external_id="11111111-1111-1111-1111-111111111111",
1401+
),
1402+
),
1403+
),
1404+
)
1405+
context = _ContextState()
1406+
await context.set_state("active_workspace", personal.model_dump())
1407+
1408+
async def fake_index(context=None):
1409+
return index
1410+
1411+
captured: dict[str, object] = {}
1412+
1413+
@asynccontextmanager
1414+
async def fake_get_client(**kwargs) -> AsyncIterator[object]:
1415+
captured["get_client_kwargs"] = kwargs
1416+
yield object()
1417+
1418+
async def fake_get_active_project(client, project, context=None, headers=None):
1419+
captured["validated_project"] = project
1420+
return _project("Hermes Memory", id=99, external_id=project)
1421+
1422+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
1423+
monkeypatch.setattr(project_context, "has_cloud_credentials", lambda _config: True)
1424+
monkeypatch.setattr("basic_memory.mcp.async_client.get_client", fake_get_client)
1425+
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
1426+
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
1427+
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
1428+
monkeypatch.setattr(project_context, "get_active_project", fake_get_active_project)
1429+
1430+
local_uuid = "55555555-5555-5555-5555-555555555555"
1431+
async with project_context.get_project_client(
1432+
project_id=local_uuid,
1433+
context=_ctx(context),
1434+
) as (_, active):
1435+
assert active.external_id == local_uuid
1436+
1437+
assert captured["get_client_kwargs"] == {}
1438+
assert captured["validated_project"] == local_uuid
1439+
assert await context.get_state("active_workspace") is None
1440+
1441+
13651442
@pytest.mark.asyncio
13661443
async def test_get_project_client_with_cloud_project_id_routes_to_workspace_with_local_config(
13671444
config_manager, monkeypatch

tests/mcp/test_tool_edit_note.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -914,8 +914,8 @@ async def fail_if_called(*args, **kwargs):
914914

915915
monkeypatch.setattr(
916916
edit_note_module,
917-
"_cloud_workspace_discovery_available",
918-
lambda config: True,
917+
"_workspace_identifier_discovery_available",
918+
lambda identifier, config: True,
919919
)
920920
monkeypatch.setattr(
921921
edit_note_module,
@@ -958,8 +958,8 @@ async def detect_workspace_project(identifier, config, context=None):
958958

959959
monkeypatch.setattr(
960960
edit_note_module,
961-
"_cloud_workspace_discovery_available",
962-
lambda config: True,
961+
"_workspace_identifier_discovery_available",
962+
lambda identifier, config: True,
963963
)
964964
monkeypatch.setattr(
965965
edit_note_module,
@@ -1001,8 +1001,8 @@ async def detect_workspace_project(raw_identifier, config, context=None):
10011001

10021002
monkeypatch.setattr(
10031003
edit_note_module,
1004-
"_cloud_workspace_discovery_available",
1005-
lambda config: True,
1004+
"_workspace_identifier_discovery_available",
1005+
lambda identifier, config: True,
10061006
)
10071007
monkeypatch.setattr(
10081008
edit_note_module,
@@ -1054,6 +1054,83 @@ async def fake_get_project_client(project, context=None, project_id=None):
10541054
assert captured_routes == [("docs/setup", None)]
10551055

10561056

1057+
@pytest.mark.asyncio
1058+
async def test_edit_note_plain_workspace_route_returns_guidance_with_local_config(
1059+
monkeypatch,
1060+
config_manager,
1061+
test_project,
1062+
):
1063+
"""Mixed local+cloud configs should still stop ambiguous plain write routes."""
1064+
from contextlib import asynccontextmanager
1065+
import importlib
1066+
1067+
import basic_memory.mcp.project_context as project_context
1068+
from basic_memory.config import ProjectEntry
1069+
from basic_memory.mcp.project_context import (
1070+
WorkspaceProjectEntry,
1071+
_build_workspace_project_index,
1072+
)
1073+
from basic_memory.schemas.cloud import WorkspaceInfo
1074+
from basic_memory.schemas.project_info import ProjectItem
1075+
1076+
edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note")
1077+
config = config_manager.load_config()
1078+
config.projects["hermes-memory"] = ProjectEntry(
1079+
path=str(config_manager.config_dir.parent / "hermes-memory")
1080+
)
1081+
config.cloud_api_key = "bmc_test123"
1082+
config_manager.save_config(config)
1083+
1084+
personal = WorkspaceInfo(
1085+
tenant_id="personal-tenant",
1086+
workspace_type="personal",
1087+
slug="personal",
1088+
name="Personal",
1089+
role="owner",
1090+
is_default=True,
1091+
)
1092+
index = _build_workspace_project_index(
1093+
(personal,),
1094+
(
1095+
WorkspaceProjectEntry(
1096+
workspace=personal,
1097+
project=ProjectItem(
1098+
id=1,
1099+
external_id="11111111-1111-1111-1111-111111111111",
1100+
name="main",
1101+
path="/tmp/main",
1102+
is_default=False,
1103+
),
1104+
),
1105+
),
1106+
)
1107+
1108+
async def fake_index(context=None):
1109+
return index
1110+
1111+
@asynccontextmanager
1112+
async def fail_if_called(*args, **kwargs):
1113+
raise AssertionError("ambiguous plain identifiers should not select a project client")
1114+
yield
1115+
1116+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
1117+
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
1118+
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
1119+
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
1120+
monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called)
1121+
1122+
result = await edit_note(
1123+
identifier="personal/main/team/plain-edit-note",
1124+
operation="append",
1125+
content="\nAppended via plain workspace-qualified permalink.",
1126+
project=None,
1127+
)
1128+
1129+
assert isinstance(result, str)
1130+
assert "# Edit Failed - Ambiguous Identifier" in result
1131+
assert 'project="personal/main"' in result
1132+
1133+
10571134
@pytest.mark.asyncio
10581135
async def test_edit_note_three_segment_plain_path_stays_local_without_workspace_discovery(
10591136
monkeypatch,
@@ -1077,8 +1154,8 @@ async def fail_if_called(*args, **kwargs):
10771154

10781155
monkeypatch.setattr(
10791156
edit_note_module,
1080-
"_cloud_workspace_discovery_available",
1081-
lambda config: False,
1157+
"_workspace_identifier_discovery_available",
1158+
lambda identifier, config: False,
10821159
)
10831160
monkeypatch.setattr(
10841161
edit_note_module,

tests/mcp/test_tool_read_content.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
from __future__ import annotations
88

9+
from contextlib import asynccontextmanager
10+
from pathlib import Path
11+
from types import SimpleNamespace
12+
913
import pytest
1014
from mcp.server.fastmcp.exceptions import ToolError
1115

@@ -157,6 +161,109 @@ async def test_read_content_allows_safe_path_integration(client, test_project):
157161
assert "safe note" in result["text"]
158162

159163

164+
@pytest.mark.asyncio
165+
async def test_read_content_workspace_memory_url_routes_with_local_config(
166+
monkeypatch,
167+
config_manager,
168+
):
169+
"""Workspace-qualified memory URLs should route even when local projects exist."""
170+
import importlib
171+
172+
import basic_memory.mcp.project_context as project_context
173+
from basic_memory.config import ProjectEntry
174+
from basic_memory.mcp.project_context import (
175+
WorkspaceProjectEntry,
176+
_build_workspace_project_index,
177+
)
178+
from basic_memory.schemas.cloud import WorkspaceInfo
179+
from basic_memory.schemas.project_info import ProjectItem
180+
181+
read_content_module = importlib.import_module("basic_memory.mcp.tools.read_content")
182+
config = config_manager.load_config()
183+
config.projects["hermes-memory"] = ProjectEntry(
184+
path=str(config_manager.config_dir.parent / "hermes-memory")
185+
)
186+
config.cloud_api_key = "bmc_test123"
187+
config_manager.save_config(config)
188+
189+
personal = WorkspaceInfo(
190+
tenant_id="personal-tenant",
191+
workspace_type="personal",
192+
slug="personal",
193+
name="Personal",
194+
role="owner",
195+
is_default=True,
196+
)
197+
project = ProjectItem(
198+
id=1,
199+
external_id="11111111-1111-1111-1111-111111111111",
200+
name="main",
201+
path="/tmp/main",
202+
is_default=False,
203+
)
204+
index = _build_workspace_project_index(
205+
(personal,),
206+
(WorkspaceProjectEntry(workspace=personal, project=project),),
207+
)
208+
209+
async def fake_index(context=None):
210+
return index
211+
212+
@asynccontextmanager
213+
async def fake_get_project_client(project=None, context=None, project_id=None):
214+
assert project == "personal/main"
215+
assert project_id is None
216+
yield (
217+
object(),
218+
SimpleNamespace(
219+
name="main",
220+
external_id="11111111-1111-1111-1111-111111111111",
221+
home=Path("/tmp/main"),
222+
),
223+
)
224+
225+
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
226+
assert identifier == "memory://personal/main/docs/report"
227+
assert project == "main"
228+
return None, "personal/main/docs/report", True
229+
230+
async def fake_resolve_entity_id(client, project_id, url):
231+
assert project_id == "11111111-1111-1111-1111-111111111111"
232+
assert url == "personal/main/docs/report"
233+
return "entity-1"
234+
235+
class FakeResponse:
236+
headers = {"content-type": "text/markdown", "content-length": "17"}
237+
text = "# Routed Content"
238+
content = b"# Routed Content"
239+
240+
async def fake_call_get(client, path, **kwargs):
241+
assert path == "/v2/projects/11111111-1111-1111-1111-111111111111/resource/entity-1"
242+
return FakeResponse()
243+
244+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
245+
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
246+
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
247+
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
248+
monkeypatch.setattr(read_content_module, "get_project_client", fake_get_project_client)
249+
monkeypatch.setattr(
250+
read_content_module,
251+
"resolve_project_and_path",
252+
fake_resolve_project_and_path,
253+
)
254+
monkeypatch.setattr(read_content_module, "resolve_entity_id", fake_resolve_entity_id)
255+
monkeypatch.setattr(read_content_module, "call_get", fake_call_get)
256+
257+
result = await read_content(path="memory://personal/main/docs/report")
258+
259+
assert result == {
260+
"type": "text",
261+
"text": "# Routed Content",
262+
"content_type": "text/markdown",
263+
"encoding": "utf-8",
264+
}
265+
266+
160267
@pytest.mark.asyncio
161268
async def test_read_content_empty_path_does_not_trigger_security_error(client, test_project):
162269
try:

0 commit comments

Comments
 (0)