Skip to content

Commit 1ad3a35

Browse files
phernandezclaude
andauthored
fix(mcp): close out the #952 manual verification findings (#981)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a148e72 commit 1ad3a35

17 files changed

Lines changed: 600 additions & 51 deletions

src/basic_memory/api/v2/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def to_summary(
147147
from_entity_id=item.from_id,
148148
from_entity_external_id=from_ext_id,
149149
to_entity=to_title,
150+
to_name=item.to_name,
150151
to_entity_id=item.to_id,
151152
to_entity_external_id=to_ext_id,
152153
created_at=item.created_at,

src/basic_memory/cli/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ def _post_command_messages() -> None:
8383
# Skip for 'mcp' command - it has its own lifespan that handles initialization
8484
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
8585
# Skip for 'reset' command - it manages its own database lifecycle
86+
# Skip for 'man' - it only copies packaged files; a broken local database
87+
# must not block installing the offline docs
8688
skip_init_commands = {
8789
"doctor",
90+
"man",
8891
"mcp",
8992
"status",
9093
"sync",

src/basic_memory/cli/commands/status.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,16 @@ async def run_status(
190190
if sync_report.total == 0:
191191
return project_item.name, sync_report
192192
if time.monotonic() >= deadline:
193+
# Why the hint: indexing is done by the sync coordinator, which
194+
# only runs inside a live server (bm mcp / hosted API). In a
195+
# CLI-only session nothing will ever drain the pending count,
196+
# so this wait cannot succeed — point at the command that
197+
# actually indexes (#959).
193198
raise StatusTimeout(
194199
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
195-
f"to finish indexing ({sync_report.total} pending change(s) remaining)."
200+
f"to finish indexing ({sync_report.total} pending change(s) remaining). "
201+
f"If no Basic Memory server is running, pending changes are never "
202+
f"indexed — run 'bm reindex --project {project_item.name}' instead."
196203
)
197204
await asyncio.sleep(poll_interval)
198205

src/basic_memory/mcp/async_client.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from loguru import logger
1111

1212
import logfire
13-
from basic_memory.config import ConfigManager, ProjectMode
13+
from basic_memory.config import ConfigManager, ProjectMode, has_cloud_credentials
1414

1515
if TYPE_CHECKING:
1616
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
@@ -368,7 +368,8 @@ async def get_client(
368368
1. Factory injection.
369369
2. Explicit routing flags (--local/--cloud).
370370
3. Per-project mode routing when project_name is provided.
371-
4. Local ASGI transport by default.
371+
4. Cloud routing when a workspace selector is provided.
372+
5. Local ASGI transport by default.
372373
"""
373374
if _client_factory:
374375
async with _client_factory(workspace=workspace) as client:
@@ -428,6 +429,26 @@ async def get_client(
428429
yield client
429430
return
430431

432+
# --- Workspace-selector routing ---
433+
# Trigger: caller passed a cloud workspace selector and nothing above routed.
434+
# Why: a workspace names a cloud tenant — silently serving the request from
435+
# the local ASGI app sent writes to the wrong destination (#954: a cloud
436+
# project create either failed on the cloud-style path or landed locally).
437+
# Outcome: route to the cloud proxy when credentials exist; without
438+
# credentials fail fast instead of pretending the operation succeeded.
439+
if workspace is not None:
440+
if not has_cloud_credentials(config):
441+
raise RuntimeError(
442+
f"A cloud workspace was requested ('{workspace}') but no cloud "
443+
"credentials were found. Run 'bm cloud login' or "
444+
"'bm cloud set-key <key>' first, or omit the workspace selector "
445+
"for a local operation."
446+
)
447+
logger.debug(f"Workspace selector '{workspace}' provided - using cloud proxy client")
448+
async with _cloud_client(config, timeout, workspace=workspace) as client:
449+
yield client
450+
return
451+
431452
# --- Default fallback ---
432453
logger.debug("Default routing - using ASGI client for local Basic Memory API")
433454
async with _asgi_client(timeout) as client:

src/basic_memory/mcp/project_context.py

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@
5454
_WORKSPACE_PROJECT_INDEX_STATE_KEY = "workspace_project_index"
5555

5656

57+
class WorkspaceProjectLookupMiss(ValueError):
58+
"""A project was absent from the workspace index (as opposed to ambiguous).
59+
60+
Misses are retried once against a freshly rebuilt index, because the
61+
session cache may simply predate an out-of-band project creation (#956).
62+
"""
63+
64+
5765
@dataclass(frozen=True)
5866
class WorkspaceProjectEntry:
5967
"""A cloud project resolved together with the workspace that owns it."""
@@ -460,6 +468,17 @@ def _canonical_memory_path_for_workspace(
460468
# Outcome: lookups preserve the complete workspace/project canonical permalink.
461469
if not normalized_remainder:
462470
normalized_remainder = project_permalink
471+
472+
# Same index-form rule as _canonical_memory_path_for_active_route (#957):
473+
# without an active workspace permalink context, stored permalinks are
474+
# project-qualified and a workspace-prefixed pattern cannot match.
475+
if "*" in normalized_remainder and current_workspace_permalink_context() is None:
476+
return build_qualified_permalink_reference(
477+
project_permalink,
478+
normalized_remainder,
479+
include_project=True,
480+
)
481+
463482
return build_qualified_permalink_reference(
464483
project_permalink,
465484
normalized_remainder,
@@ -477,6 +496,24 @@ def _canonical_memory_path_for_active_route(
477496
) -> str:
478497
"""Return the canonical permalink path for the currently routed project/workspace."""
479498
project_prefix = active_project.permalink
499+
500+
# Trigger: the path contains a glob wildcard (folder/*) and no server-side
501+
# workspace permalink context is active.
502+
# Why: patterns match raw against the search index, so they must mirror the
503+
# stored permalink form. The contextvar is what qualified permalinks at
504+
# write time — when it is absent, stored rows are project-qualified and a
505+
# workspace prefix (from the client's cached_workspace display state)
506+
# guarantees zero matches (#957). Direct lookups keep full qualification
507+
# because the link resolver understands it; patterns have no fallback.
508+
# Outcome: without the contextvar, qualify patterns with the project prefix
509+
# only; with it, fall through to normal workspace canonicalization.
510+
if "*" in path and current_workspace_permalink_context() is None:
511+
if not include_project:
512+
return path
513+
if path == project_prefix or path.startswith(f"{project_prefix}/"):
514+
return path
515+
return f"{project_prefix}/{path}"
516+
480517
workspace_remainder = path
481518
if include_project and (path == project_prefix or path.startswith(f"{project_prefix}/")):
482519
# Trigger: the memory URL already names the active project root/prefix
@@ -722,9 +759,15 @@ async def _fetch_workspace_project_entries(
722759

723760
async def _ensure_workspace_project_index(
724761
context: Optional[Context] = None,
762+
*,
763+
force_refresh: bool = False,
725764
) -> WorkspaceProjectIndex:
726-
"""Build or load the session-local workspace/project lookup index."""
727-
if context:
765+
"""Build or load the session-local workspace/project lookup index.
766+
767+
force_refresh bypasses the cached index and rebuilds from discovery —
768+
used by resolve_workspace_project_identifier when a lookup misses (#956).
769+
"""
770+
if context and not force_refresh:
728771
cached_raw = await context.get_state(_WORKSPACE_PROJECT_INDEX_STATE_KEY)
729772
cached_index = _workspace_project_index_from_state(cached_raw)
730773
if cached_index is not None:
@@ -865,7 +908,32 @@ async def resolve_workspace_project_identifier(
865908
) -> WorkspaceProjectEntry:
866909
"""Resolve a project by external_id (UUID), qualified name, or unqualified name."""
867910
index = await _ensure_workspace_project_index(context=context)
911+
try:
912+
return await _resolve_workspace_project_from_index(index, project, context)
913+
except WorkspaceProjectLookupMiss:
914+
# Trigger: the lookup missed the session-cached index.
915+
# Why: a miss is exactly the signal the cache may be stale — projects
916+
# created out-of-band (CLI, a teammate in a shared workspace) post-date
917+
# the index built at session start (#956).
918+
# Outcome: rebuild the index once and retry; a second miss is authoritative
919+
# and its error (with the refreshed project list) propagates.
920+
logger.info(
921+
f"Workspace project lookup missed for '{project}'; refreshing index and retrying"
922+
)
923+
refreshed = await _ensure_workspace_project_index(context=context, force_refresh=True)
924+
return await _resolve_workspace_project_from_index(refreshed, project, context)
925+
926+
927+
async def _resolve_workspace_project_from_index(
928+
index: WorkspaceProjectIndex,
929+
project: str,
930+
context: Optional[Context] = None,
931+
) -> WorkspaceProjectEntry:
932+
"""Resolve a project against one concrete index snapshot.
868933
934+
Raises WorkspaceProjectLookupMiss for absent projects (retryable via index
935+
refresh) and plain ValueError for ambiguity, which a refresh cannot fix.
936+
"""
869937
# Fast path: direct lookup by external_id when the identifier is a UUID
870938
# Canonicalize via str(UUID(...)) so uppercase, brace-wrapped, or urn:uuid forms
871939
# all hash to the same lowercase-hyphenated key as the stored external_ids.
@@ -895,7 +963,7 @@ async def resolve_workspace_project_identifier(
895963
failed_workspace.tenant_id == workspace.tenant_id
896964
for failed_workspace in index.failed_workspaces
897965
):
898-
raise ValueError(
966+
raise WorkspaceProjectLookupMiss(
899967
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
900968
"could not be loaded. Retry after workspace discovery recovers."
901969
)
@@ -904,7 +972,7 @@ async def resolve_workspace_project_identifier(
904972
for entry in index.entries
905973
if entry.workspace.tenant_id == workspace.tenant_id
906974
)
907-
raise ValueError(
975+
raise WorkspaceProjectLookupMiss(
908976
f"Project '{project_identifier}' was not found in workspace "
909977
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
910978
)
@@ -929,7 +997,7 @@ async def resolve_workspace_project_identifier(
929997
"retry or use a qualified project from an indexed workspace."
930998
)
931999
available = ", ".join(entry.qualified_name for entry in index.entries)
932-
raise ValueError(
1000+
raise WorkspaceProjectLookupMiss(
9331001
f"Project '{project}' was not found in indexed cloud workspaces. "
9341002
f"Available projects: {available}.{failed_note}"
9351003
)

src/basic_memory/mcp/tools/build_context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ def _format_entity_block(result: ContextResult) -> str:
5454
lines.append("")
5555
lines.append("### Relations")
5656
for rel in relation_items:
57-
lines.append(f"- {rel.relation_type} [[{rel.to_entity}]]")
57+
# Unresolved forward references have no resolved entity yet; fall back
58+
# to the literal target text instead of rendering [[None]] (#955)
59+
target = rel.to_entity or rel.to_name
60+
lines.append(f"- {rel.relation_type} [[{target}]]")
5861

5962
# --- Related entities (non-relation related results) ---
6063
related_entities: list[EntitySummary | ObservationSummary] = [

src/basic_memory/mcp/tools/project_management.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -477,10 +477,14 @@ async def _resolve_workspace_routing(
477477
if workspace is None:
478478
return None
479479

480-
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
480+
forced_local = _explicit_routing() and _force_local_mode()
481481
config = ConfigManager().config
482+
# Resolve whenever credentials make workspace discovery possible — not only
483+
# under explicit --cloud. A workspace selector implies cloud routing
484+
# (get_client routes it to the cloud proxy, #954), and the transport needs
485+
# the tenant id in X-Workspace-ID, not a slug or display name.
482486
should_resolve_workspace = is_factory_mode() or (
483-
explicit_cloud_routing and has_cloud_credentials(config)
487+
has_cloud_credentials(config) and not forced_local
484488
)
485489
if not should_resolve_workspace:
486490
return workspace
@@ -521,8 +525,9 @@ async def create_memory_project(
521525
workspace: Optional cloud workspace selector to create the project in. Slug is
522526
preferred for AI callers, but tenant_id and unique name are also accepted.
523527
When omitted, the connection's default workspace is used. Discover values
524-
via `list_workspaces`. In local mode the selector is passed through
525-
without slug resolution.
528+
via `list_workspaces`. A workspace selector implies cloud routing:
529+
without cloud credentials the call fails fast instead of silently
530+
creating a local project (#954).
526531
output_format: "text" returns the existing human-readable result text.
527532
"json" returns structured project creation metadata.
528533
context: Optional FastMCP context for progress/status logging.
@@ -660,8 +665,9 @@ async def delete_project(
660665
workspace: Optional cloud workspace selector to delete the project from.
661666
Slug is preferred for AI callers, but tenant_id and unique name are
662667
also accepted. When omitted, the connection's default workspace is
663-
used. In local mode the selector is passed through without slug
664-
resolution, matching create_memory_project behavior.
668+
used. A workspace selector implies cloud routing: without cloud
669+
credentials the call fails fast, matching create_memory_project
670+
behavior (#954).
665671
666672
Returns:
667673
Confirmation message about project deletion

src/basic_memory/repository/search_index_row.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class SearchIndexRow:
3636
category: Optional[str] = None # observations
3737
from_id: Optional[int] = None # relations
3838
to_id: Optional[int] = None # relations
39+
to_name: Optional[str] = None # relations: literal target text, set even when unresolved
3940
relation_type: Optional[str] = None # relations
4041

4142
# Matched chunk text from vector search (the actual content that matched the query)
@@ -94,6 +95,7 @@ def to_insert(self, serialize_json: bool = True):
9495
else self.metadata,
9596
"from_id": self.from_id,
9697
"to_id": self.to_id,
98+
"to_name": self.to_name,
9799
"relation_type": self.relation_type,
98100
"entity_id": self.entity_id,
99101
"category": self.category,

src/basic_memory/schemas/memory.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ class RelationSummary(BaseModel):
155155
from_entity_id: Optional[int] = None
156156
from_entity_external_id: Optional[str] = None
157157
to_entity: Optional[str] = None
158+
# Literal target text from the markdown; present even when the relation is
159+
# an unresolved forward reference (to_entity is None until the target exists)
160+
to_name: Optional[str] = None
158161
to_entity_id: Optional[int] = None
159162
to_entity_external_id: Optional[str] = None
160163
created_at: Annotated[

0 commit comments

Comments
 (0)