Skip to content

Commit 057c500

Browse files
committed
fix(mcp): preserve local memory url fallback
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent aa1472d commit 057c500

4 files changed

Lines changed: 121 additions & 20 deletions

File tree

src/basic_memory/api/app.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from basic_memory.workspace_context import (
3434
WORKSPACE_SLUG_HEADER,
3535
WORKSPACE_TYPE_HEADER,
36-
validate_workspace_permalink_context_values,
36+
workspace_permalink_context_validation_error,
3737
workspace_permalink_context,
3838
)
3939

@@ -101,12 +101,11 @@ async def workspace_permalink_context_middleware(request: Request, call_next):
101101
workspace_slug = request.headers.get(WORKSPACE_SLUG_HEADER)
102102
workspace_type = request.headers.get(WORKSPACE_TYPE_HEADER)
103103

104-
try:
105-
validate_workspace_permalink_context_values(workspace_slug, workspace_type)
106-
except ValueError as exc:
104+
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
105+
if validation_error is not None:
107106
return JSONResponse(
108107
status_code=400,
109-
content={"detail": str(exc)},
108+
content={"detail": validation_error},
110109
)
111110

112111
if not workspace_slug:

src/basic_memory/mcp/project_context.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,13 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
433433
if _explicit_routing() and _force_local_mode():
434434
return False
435435

436+
# Trigger: local project config is present even though cloud credentials are saved.
437+
# Why: existing local `memory://...` URLs must not depend on workspace discovery.
438+
# Outcome: only factory, explicit cloud, or cloud-only sessions attempt discovery here.
436439
return (
437440
is_factory_mode()
438441
or (_explicit_routing() and not _force_local_mode())
439-
or has_cloud_credentials(config)
442+
or (not config.projects and has_cloud_credentials(config))
440443
)
441444

442445

@@ -474,15 +477,14 @@ async def resolve_workspace_qualified_memory_url(
474477
"could not be loaded. Retry after workspace discovery recovers."
475478
)
476479

477-
available = ", ".join(
478-
entry.qualified_name
479-
for entry in index.entries
480-
if entry.workspace.tenant_id == workspace.tenant_id
481-
)
482-
raise ValueError(
483-
f"Project '{project_identifier}' was not found in workspace "
484-
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
485-
)
480+
# Trigger: first segment matches a workspace slug but the second does not
481+
# match a project in that workspace.
482+
# Why: workspace-qualified URLs require both route segments to match; otherwise
483+
# existing project-prefixed URLs like `memory://main/notes/foo` can collide
484+
# with a workspace slug named `main`.
485+
# Outcome: treat this as not workspace-qualified and let the caller use
486+
# the existing project-prefix/default-project resolver.
487+
return None
486488
if len(matches) > 1:
487489
details = ", ".join(
488490
f"{entry.qualified_name} ({entry.project.external_id})" for entry in matches
@@ -1121,6 +1123,10 @@ async def detect_project_from_memory_url_prefix(
11211123
if not identifier.strip().startswith("memory://"):
11221124
return None
11231125

1126+
local_project = detect_project_from_url_prefix(identifier, config)
1127+
if local_project is not None:
1128+
return local_project
1129+
11241130
if _cloud_workspace_discovery_available(config):
11251131
resolution = await resolve_workspace_qualified_memory_url(
11261132
identifier,
@@ -1129,7 +1135,7 @@ async def detect_project_from_memory_url_prefix(
11291135
if resolution is not None:
11301136
return resolution.project_identifier
11311137

1132-
return detect_project_from_url_prefix(identifier, config)
1138+
return None
11331139

11341140

11351141
@asynccontextmanager

src/basic_memory/workspace_context.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,30 @@ def validate_workspace_permalink_context_values(
4040
workspace_type: str | None,
4141
) -> None:
4242
"""Validate workspace permalink metadata before it can affect stored permalinks."""
43+
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
44+
if validation_error is not None:
45+
raise ValueError(validation_error)
46+
47+
48+
def workspace_permalink_context_validation_error(
49+
workspace_slug: str | None,
50+
workspace_type: str | None,
51+
) -> str | None:
52+
"""Return the validation error for workspace permalink metadata, if any."""
4353
if bool(workspace_slug) != bool(workspace_type):
44-
raise ValueError("workspace_slug and workspace_type must be provided together")
54+
return "workspace_slug and workspace_type must be provided together"
4555

4656
if not workspace_slug or not workspace_type:
47-
return
57+
return None
4858

4959
if _WORKSPACE_SLUG_PATTERN.fullmatch(workspace_slug) is None:
50-
raise ValueError(f"{WORKSPACE_SLUG_HEADER} must match [a-z0-9_-]+")
60+
return f"{WORKSPACE_SLUG_HEADER} must match [a-z0-9_-]+"
5161

5262
if workspace_type not in _WORKSPACE_TYPES:
5363
allowed = ", ".join(sorted(_WORKSPACE_TYPES))
54-
raise ValueError(f"{WORKSPACE_TYPE_HEADER} must be one of: {allowed}")
64+
return f"{WORKSPACE_TYPE_HEADER} must be one of: {allowed}"
65+
66+
return None
5567

5668

5769
@contextmanager

tests/mcp/test_project_context.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,90 @@ async def fake_index(context=None):
663663
assert resolved == "team-paul/main"
664664

665665

666+
@pytest.mark.asyncio
667+
async def test_detect_project_from_memory_url_prefix_prefers_local_project_prefix(
668+
monkeypatch,
669+
):
670+
import basic_memory.mcp.project_context as project_context
671+
from basic_memory.config import BasicMemoryConfig, ProjectEntry
672+
from basic_memory.mcp.project_context import detect_project_from_memory_url_prefix
673+
674+
async def fail_if_called(context=None): # pragma: no cover
675+
raise AssertionError("Local project-prefixed memory URLs must not discover workspaces")
676+
677+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_if_called)
678+
679+
resolved = await detect_project_from_memory_url_prefix(
680+
"memory://main/notes/foo",
681+
BasicMemoryConfig(
682+
projects={"main": ProjectEntry(path="/tmp/main")},
683+
cloud_api_key="bmc_test123",
684+
),
685+
)
686+
687+
assert resolved == "main"
688+
689+
690+
@pytest.mark.asyncio
691+
async def test_detect_project_from_memory_url_prefix_skips_workspace_discovery_for_local_config(
692+
monkeypatch,
693+
):
694+
import basic_memory.mcp.project_context as project_context
695+
from basic_memory.config import BasicMemoryConfig, ProjectEntry
696+
from basic_memory.mcp.project_context import detect_project_from_memory_url_prefix
697+
698+
async def fail_if_called(context=None): # pragma: no cover
699+
raise AssertionError("Saved cloud credentials must not force local workspace discovery")
700+
701+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fail_if_called)
702+
703+
resolved = await detect_project_from_memory_url_prefix(
704+
"memory://notes/foo/bar",
705+
BasicMemoryConfig(
706+
projects={"main": ProjectEntry(path="/tmp/main")},
707+
cloud_api_key="bmc_test123",
708+
),
709+
)
710+
711+
assert resolved is None
712+
713+
714+
@pytest.mark.asyncio
715+
async def test_resolve_workspace_qualified_memory_url_ignores_workspace_project_miss(
716+
monkeypatch,
717+
):
718+
import basic_memory.mcp.project_context as project_context
719+
from basic_memory.mcp.project_context import (
720+
WorkspaceProjectEntry,
721+
_build_workspace_project_index,
722+
resolve_workspace_qualified_memory_url,
723+
)
724+
725+
workspace = _workspace(
726+
tenant_id="main-tenant",
727+
workspace_type="organization",
728+
slug="main",
729+
name="Main Workspace",
730+
role="editor",
731+
)
732+
entries = (
733+
WorkspaceProjectEntry(
734+
workspace=workspace,
735+
project=_project("research", id=1, external_id="research-project-id"),
736+
),
737+
)
738+
index = _build_workspace_project_index((workspace,), entries)
739+
740+
async def fake_index(context=None):
741+
return index
742+
743+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
744+
745+
resolved = await resolve_workspace_qualified_memory_url("memory://main/notes/foo")
746+
747+
assert resolved is None
748+
749+
666750
@pytest.mark.asyncio
667751
async def test_resolve_workspace_qualified_memory_url_fails_on_duplicate_project_permalink(
668752
monkeypatch,

0 commit comments

Comments
 (0)