Skip to content

Commit 33e741f

Browse files
groksrcclaude
andcommitted
fix(mcp): resolve workspace display names and tenant ids in qualified project routes
The edit_note docstring (and write_note in PR #964) advertise that the workspace segment of a "workspace/project" route may be a slug, name, or tenant_id, but resolve_workspace_project_identifier() only matched against WorkspaceInfo.slug. Cloud routes using a display name or tenant UUID failed with "Workspace ... was not found" despite the documented contract. Extend the first-segment matching (only the matching logic; the overall resolution flow is unchanged) to honor, in priority order: 1. slug (casefold) — unchanged, checked first so working routes keep meaning 2. tenant_id — exact match on the opaque id 3. display name (casefold) — fails fast on collisions, listing candidate slugs A name that collides with another workspace's slug resolves to the slug owner. Unknown identifiers raise a not-found error naming the forms that were tried. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent c977037 commit 33e741f

2 files changed

Lines changed: 231 additions & 15 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,67 @@ async def ensure_workspace_project_index(
798798
return await _ensure_workspace_project_index(context=context)
799799

800800

801+
def _match_workspace_identifier(
802+
workspaces: tuple[WorkspaceInfo, ...],
803+
workspace_identifier: str,
804+
) -> WorkspaceInfo:
805+
"""Resolve the first segment of a qualified route to a single workspace.
806+
807+
The edit_note/write_note contract advertises that the workspace segment may be a
808+
slug, tenant_id, or display name. We honor those forms in a fixed priority order so
809+
that adding tenant_id/name support never changes the meaning of an identifier that
810+
already resolves today:
811+
812+
1. slug (casefold) — existing behavior, checked first so working routes are stable.
813+
2. tenant_id — exact match against the opaque id (no casefolding, mirroring the
814+
precedent in ``workspace_matches_exact_identifier``).
815+
3. display name (casefold) — names are not guaranteed unique, so a name that matches
816+
multiple workspaces is rejected rather than silently picking one.
817+
"""
818+
# Trigger: identifier equals a workspace slug (casefold).
819+
# Why: slug is the canonical routing key; resolving it first guarantees a workspace
820+
# whose display name collides with another workspace's slug yields to the slug owner.
821+
# Outcome: return the slug owner before tenant_id/name are considered.
822+
slug_matches = [
823+
workspace
824+
for workspace in workspaces
825+
if workspace.slug.casefold() == workspace_identifier.casefold()
826+
]
827+
if slug_matches:
828+
return slug_matches[0]
829+
830+
# Trigger: identifier exactly equals a workspace tenant_id (an opaque id).
831+
# Why: tenant_ids are unique, so an exact hit is unambiguous and needs no tie-break.
832+
tenant_matches = [
833+
workspace for workspace in workspaces if workspace.tenant_id == workspace_identifier
834+
]
835+
if tenant_matches:
836+
return tenant_matches[0]
837+
838+
# Trigger: identifier matches one or more workspace display names (casefold).
839+
# Why: names are not guaranteed unique; failing fast on collisions keeps routing
840+
# deterministic and tells the caller exactly how to disambiguate.
841+
name_matches = [
842+
workspace
843+
for workspace in workspaces
844+
if workspace.name.casefold() == workspace_identifier.casefold()
845+
]
846+
if len(name_matches) > 1:
847+
candidates = ", ".join(workspace.slug for workspace in name_matches)
848+
raise ValueError(
849+
f"Workspace name '{workspace_identifier}' matched multiple workspaces "
850+
f"(slugs: {candidates}). Use the workspace slug or tenant_id to disambiguate."
851+
)
852+
if name_matches:
853+
return name_matches[0]
854+
855+
available = ", ".join(workspace.slug for workspace in workspaces)
856+
raise ValueError(
857+
f"Workspace '{workspace_identifier}' was not found by slug, tenant_id, or name. "
858+
f"Available workspace slugs: {available}"
859+
)
860+
861+
801862
async def resolve_workspace_project_identifier(
802863
project: str,
803864
context: Optional[Context] = None,
@@ -816,23 +877,14 @@ async def resolve_workspace_project_identifier(
816877
except ValueError:
817878
pass
818879

819-
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
880+
workspace_identifier, project_identifier = _split_qualified_project_identifier(project)
820881
project_permalink = generate_permalink(project_identifier)
821882

822-
if workspace_slug:
823-
workspace_matches = [
824-
workspace
825-
for workspace in index.workspaces
826-
if workspace.slug.casefold() == workspace_slug.casefold()
827-
]
828-
if not workspace_matches:
829-
available = ", ".join(workspace.slug for workspace in index.workspaces)
830-
raise ValueError(
831-
f"Workspace '{workspace_slug}' was not found. "
832-
f"Available workspace slugs: {available}"
833-
)
834-
835-
workspace = workspace_matches[0]
883+
if workspace_identifier:
884+
# Honor the documented "slug, name, or tenant_id" contract for the workspace
885+
# segment; _match_workspace_identifier raises a clear error on ambiguous names
886+
# and unknown identifiers, listing what forms were tried.
887+
workspace = _match_workspace_identifier(index.workspaces, workspace_identifier)
836888
matches = [
837889
entry
838890
for entry in index.entries_by_permalink.get(project_permalink, ())

tests/mcp/test_project_context.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,170 @@ async def fake_index(context=None):
694694
assert resolved.project.external_id == "personal-project-id"
695695

696696

697+
def _patch_index(monkeypatch, workspaces, entries):
698+
"""Install a fake workspace/project index for resolver tests."""
699+
import basic_memory.mcp.project_context as project_context
700+
from basic_memory.mcp.project_context import _build_workspace_project_index
701+
702+
index = _build_workspace_project_index(workspaces, entries)
703+
704+
async def fake_index(context=None):
705+
return index
706+
707+
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
708+
709+
710+
@pytest.mark.asyncio
711+
async def test_resolve_workspace_identifier_by_slug_tenant_id_and_name(monkeypatch):
712+
"""Qualified routes resolve the workspace segment by slug, tenant_id, or display name."""
713+
from basic_memory.mcp.project_context import (
714+
WorkspaceProjectEntry,
715+
resolve_workspace_project_identifier,
716+
)
717+
718+
acme = _workspace(
719+
tenant_id="acme-tenant-uuid",
720+
workspace_type="organization",
721+
slug="acme-slug",
722+
name="Acme Corp",
723+
role="editor",
724+
)
725+
entries = (
726+
WorkspaceProjectEntry(
727+
workspace=acme,
728+
project=_project("Meeting Notes", id=1, external_id="acme-project-id"),
729+
),
730+
)
731+
_patch_index(monkeypatch, (acme,), entries)
732+
733+
# slug (existing behavior, stays green)
734+
by_slug = await resolve_workspace_project_identifier("acme-slug/meeting-notes")
735+
assert by_slug.project.external_id == "acme-project-id"
736+
737+
# tenant_id (exact, opaque id)
738+
by_tenant = await resolve_workspace_project_identifier("acme-tenant-uuid/meeting-notes")
739+
assert by_tenant.project.external_id == "acme-project-id"
740+
741+
# display name, case-insensitive
742+
by_name = await resolve_workspace_project_identifier("ACME corp/meeting-notes")
743+
assert by_name.project.external_id == "acme-project-id"
744+
745+
746+
@pytest.mark.asyncio
747+
async def test_resolve_workspace_identifier_ambiguous_name_lists_candidates(monkeypatch):
748+
"""A display name shared by multiple workspaces fails fast naming the candidate slugs."""
749+
from basic_memory.mcp.project_context import (
750+
WorkspaceProjectEntry,
751+
resolve_workspace_project_identifier,
752+
)
753+
754+
alpha = _workspace(
755+
tenant_id="alpha-tenant",
756+
workspace_type="organization",
757+
slug="research-alpha",
758+
name="Research",
759+
role="editor",
760+
)
761+
beta = _workspace(
762+
tenant_id="beta-tenant",
763+
workspace_type="organization",
764+
slug="research-beta",
765+
name="Research",
766+
role="editor",
767+
)
768+
entries = (
769+
WorkspaceProjectEntry(
770+
workspace=alpha,
771+
project=_project("Notes", id=1, external_id="alpha-project-id"),
772+
),
773+
WorkspaceProjectEntry(
774+
workspace=beta,
775+
project=_project("Notes", id=2, external_id="beta-project-id"),
776+
),
777+
)
778+
_patch_index(monkeypatch, (alpha, beta), entries)
779+
780+
with pytest.raises(ValueError) as exc_info:
781+
await resolve_workspace_project_identifier("Research/notes")
782+
783+
message = str(exc_info.value)
784+
assert "matched multiple workspaces" in message
785+
assert "research-alpha" in message
786+
assert "research-beta" in message
787+
assert "slug or tenant_id" in message
788+
789+
790+
@pytest.mark.asyncio
791+
async def test_resolve_workspace_identifier_unknown_lists_tried_forms(monkeypatch):
792+
"""An unknown workspace identifier reports the slug/tenant_id/name forms that were tried."""
793+
from basic_memory.mcp.project_context import (
794+
WorkspaceProjectEntry,
795+
resolve_workspace_project_identifier,
796+
)
797+
798+
acme = _workspace(
799+
tenant_id="acme-tenant",
800+
workspace_type="organization",
801+
slug="acme",
802+
name="Acme",
803+
role="editor",
804+
)
805+
entries = (
806+
WorkspaceProjectEntry(
807+
workspace=acme,
808+
project=_project("Notes", id=1, external_id="acme-project-id"),
809+
),
810+
)
811+
_patch_index(monkeypatch, (acme,), entries)
812+
813+
with pytest.raises(ValueError) as exc_info:
814+
await resolve_workspace_project_identifier("nonexistent/notes")
815+
816+
message = str(exc_info.value)
817+
assert "was not found by slug, tenant_id, or name" in message
818+
assert "acme" in message
819+
820+
821+
@pytest.mark.asyncio
822+
async def test_resolve_workspace_identifier_slug_wins_over_name_collision(monkeypatch):
823+
"""A name that equals another workspace's slug resolves to the slug owner."""
824+
from basic_memory.mcp.project_context import (
825+
WorkspaceProjectEntry,
826+
resolve_workspace_project_identifier,
827+
)
828+
829+
# slug_owner.slug == "shared"; name_owner.name == "shared" — the slug owner must win.
830+
slug_owner = _workspace(
831+
tenant_id="slug-owner-tenant",
832+
workspace_type="organization",
833+
slug="shared",
834+
name="Slug Owner",
835+
role="editor",
836+
)
837+
name_owner = _workspace(
838+
tenant_id="name-owner-tenant",
839+
workspace_type="organization",
840+
slug="name-owner-slug",
841+
name="shared",
842+
role="editor",
843+
)
844+
entries = (
845+
WorkspaceProjectEntry(
846+
workspace=slug_owner,
847+
project=_project("Notes", id=1, external_id="slug-owner-project-id"),
848+
),
849+
WorkspaceProjectEntry(
850+
workspace=name_owner,
851+
project=_project("Notes", id=2, external_id="name-owner-project-id"),
852+
),
853+
)
854+
_patch_index(monkeypatch, (slug_owner, name_owner), entries)
855+
856+
resolved = await resolve_workspace_project_identifier("shared/notes")
857+
assert resolved.workspace.slug == "shared"
858+
assert resolved.project.external_id == "slug-owner-project-id"
859+
860+
697861
@pytest.mark.asyncio
698862
async def test_detect_project_from_memory_url_prefix_resolves_workspace_slug(monkeypatch):
699863
import basic_memory.mcp.project_context as project_context

0 commit comments

Comments
 (0)