Skip to content

Commit bcc5758

Browse files
committed
fix(mcp): route workspace-qualified memory urls
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0b33547 commit bcc5758

17 files changed

Lines changed: 698 additions & 38 deletions

src/basic_memory/api/app.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from fastapi import FastAPI, HTTPException, Request
66
from fastapi.exception_handlers import http_exception_handler
7+
from fastapi.responses import JSONResponse
78
from fastapi.routing import APIRouter
89
from loguru import logger
910

@@ -29,6 +30,11 @@
2930
from basic_memory.config import init_api_logging
3031
from basic_memory.services.exceptions import EntityAlreadyExistsError
3132
from basic_memory.services.initialization import initialize_app
33+
from basic_memory.workspace_context import (
34+
WORKSPACE_SLUG_HEADER,
35+
WORKSPACE_TYPE_HEADER,
36+
workspace_permalink_context,
37+
)
3238

3339

3440
@asynccontextmanager
@@ -87,6 +93,33 @@ async def lifespan(app: FastAPI): # pragma: no cover
8793
lifespan=lifespan,
8894
)
8995

96+
97+
@app.middleware("http")
98+
async def workspace_permalink_context_middleware(request: Request, call_next):
99+
"""Populate workspace permalink context from request headers."""
100+
workspace_slug = request.headers.get(WORKSPACE_SLUG_HEADER)
101+
workspace_type = request.headers.get(WORKSPACE_TYPE_HEADER)
102+
103+
if bool(workspace_slug) != bool(workspace_type):
104+
return JSONResponse(
105+
status_code=400,
106+
content={
107+
"detail": (
108+
f"{WORKSPACE_SLUG_HEADER} and {WORKSPACE_TYPE_HEADER} must be provided together"
109+
)
110+
},
111+
)
112+
113+
if not workspace_slug:
114+
return await call_next(request)
115+
116+
with workspace_permalink_context(
117+
workspace_slug=workspace_slug,
118+
workspace_type=workspace_type,
119+
):
120+
return await call_next(request)
121+
122+
90123
# Include v2 routers FIRST (more specific paths must match before /{project} catch-all)
91124
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
92125
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")

src/basic_memory/mcp/async_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,12 @@ async def _cloud_client(
9494
workspace: Optional[str] = None,
9595
) -> AsyncIterator[AsyncClient]:
9696
"""Create a cloud proxy client with resolved credentials."""
97+
from basic_memory.workspace_context import workspace_permalink_headers
98+
9799
token = await _resolve_cloud_token(config)
98100
proxy_base_url = f"{config.cloud_host}/proxy"
99101
headers = {"Authorization": f"Bearer {token}"}
102+
headers.update(workspace_permalink_headers())
100103
if workspace:
101104
headers["X-Workspace-ID"] = workspace
102105
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")

src/basic_memory/mcp/project_context.py

Lines changed: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010

1111
import asyncio
12-
from contextlib import asynccontextmanager
12+
from contextlib import asynccontextmanager, nullcontext
1313
from dataclasses import dataclass, field
1414
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
1515
from uuid import UUID
@@ -30,6 +30,10 @@
3030
from basic_memory.schemas.v2 import ProjectResolveResponse
3131
from basic_memory.schemas.memory import memory_url_path
3232
from basic_memory.utils import generate_permalink, normalize_project_reference
33+
from basic_memory.workspace_context import (
34+
current_workspace_permalink_context,
35+
workspace_permalink_context,
36+
)
3337

3438
# --- Workspace provider injection ---
3539
# Mirrors the set_client_factory() pattern in async_client.py.
@@ -62,6 +66,18 @@ class WorkspaceProjectIndex:
6266
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
6367

6468

69+
@dataclass(frozen=True)
70+
class WorkspaceMemoryUrlResolution:
71+
"""Resolved workspace/project route for a workspace-qualified memory URL."""
72+
73+
entry: WorkspaceProjectEntry
74+
canonical_path: str
75+
76+
@property
77+
def project_identifier(self) -> str:
78+
return self.entry.qualified_name
79+
80+
6581
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
6682
"""Override workspace discovery (for cloud app, testing, etc)."""
6783
global _workspace_provider
@@ -390,6 +406,89 @@ def _unqualified_project_identifier(identifier: str) -> str:
390406
return project_identifier
391407

392408

409+
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
410+
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
411+
if not identifier.strip().startswith("memory://"):
412+
return None
413+
414+
normalized = normalize_project_reference(memory_url_path(identifier))
415+
parts = normalized.split("/", 2)
416+
if len(parts) != 3:
417+
return None
418+
419+
workspace_slug, project_identifier, remainder = parts
420+
if not workspace_slug or not project_identifier or not remainder:
421+
return None
422+
return workspace_slug, project_identifier, remainder
423+
424+
425+
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
426+
"""Return True when workspace discovery can be used without forcing local routing."""
427+
from basic_memory.mcp.async_client import (
428+
_explicit_routing,
429+
_force_local_mode,
430+
is_factory_mode,
431+
)
432+
433+
if _explicit_routing() and _force_local_mode():
434+
return False
435+
436+
return (
437+
is_factory_mode()
438+
or (_explicit_routing() and not _force_local_mode())
439+
or has_cloud_credentials(config)
440+
)
441+
442+
443+
async def resolve_workspace_qualified_memory_url(
444+
identifier: str,
445+
context: Optional[Context] = None,
446+
) -> WorkspaceMemoryUrlResolution | None:
447+
"""Resolve a workspace-qualified memory URL against accessible workspaces."""
448+
segments = _split_workspace_memory_url_segments(identifier)
449+
if segments is None:
450+
return None
451+
452+
workspace_slug, project_identifier, remainder = segments
453+
index = await _ensure_workspace_project_index(context=context)
454+
workspace = next(
455+
(item for item in index.workspaces if item.slug.casefold() == workspace_slug.casefold()),
456+
None,
457+
)
458+
if workspace is None:
459+
return None
460+
461+
project_permalink = generate_permalink(project_identifier)
462+
matches = [
463+
entry
464+
for entry in index.entries_by_permalink.get(project_permalink, ())
465+
if entry.workspace.tenant_id == workspace.tenant_id
466+
]
467+
if not matches:
468+
if any(
469+
failed_workspace.tenant_id == workspace.tenant_id
470+
for failed_workspace in index.failed_workspaces
471+
):
472+
raise ValueError(
473+
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
474+
"could not be loaded. Retry after workspace discovery recovers."
475+
)
476+
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+
)
486+
487+
entry = matches[0]
488+
canonical_path = f"{entry.workspace.slug}/{entry.project.permalink}/{remainder}"
489+
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
490+
491+
393492
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
394493
"""Format qualified project choices for collision errors."""
395494
return " or ".join(entry.qualified_name for entry in entries)
@@ -856,13 +955,33 @@ async def resolve_project_and_path(
856955
return active_project, identifier, False
857956

858957
normalized_path = normalize_project_reference(memory_url_path(identifier))
958+
cached_project = await _get_cached_active_project(context)
959+
cached_workspace = await _get_cached_active_workspace(context)
960+
if cached_project and cached_workspace:
961+
workspace_prefix = generate_permalink(cached_workspace.slug)
962+
qualified_prefix = f"{workspace_prefix}/{cached_project.permalink}"
963+
if normalized_path == qualified_prefix or normalized_path.startswith(
964+
f"{qualified_prefix}/"
965+
):
966+
return cached_project, normalized_path, True
967+
968+
workspace_context = current_workspace_permalink_context()
969+
if workspace_context and project:
970+
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
971+
project_permalink = generate_permalink(_unqualified_project_identifier(project))
972+
qualified_prefix = f"{workspace_prefix}/{project_permalink}"
973+
if normalized_path == qualified_prefix or normalized_path.startswith(
974+
f"{qualified_prefix}/"
975+
):
976+
active_project = await get_active_project(client, project, context, headers)
977+
return active_project, normalized_path, True
978+
859979
project_prefix, remainder = _split_project_prefix(normalized_path)
860980
include_project = config.permalinks_include_project
861981
# Trigger: memory URL begins with a potential project segment
862982
# Why: allow project-scoped memory URLs without requiring a separate project parameter
863983
# Outcome: attempt to resolve the prefix as a project and route to it
864984
if project_prefix:
865-
cached_project = await _get_cached_active_project(context)
866985
if cached_project and _project_matches_identifier(cached_project, project_prefix):
867986
resolved_project = await resolve_project_parameter(project_prefix, context=context)
868987
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
@@ -975,6 +1094,26 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -
9751094
return None
9761095

9771096

1097+
async def detect_project_from_memory_url_prefix(
1098+
identifier: str,
1099+
config: BasicMemoryConfig,
1100+
context: Optional[Context] = None,
1101+
) -> Optional[str]:
1102+
"""Resolve a project from a memory URL prefix, including workspace-qualified URLs."""
1103+
if not identifier.strip().startswith("memory://"):
1104+
return None
1105+
1106+
if _cloud_workspace_discovery_available(config):
1107+
resolution = await resolve_workspace_qualified_memory_url(
1108+
identifier,
1109+
context=context,
1110+
)
1111+
if resolution is not None:
1112+
return resolution.project_identifier
1113+
1114+
return detect_project_from_url_prefix(identifier, config)
1115+
1116+
9781117
@asynccontextmanager
9791118
async def get_project_client(
9801119
project: Optional[str] = None,
@@ -1113,12 +1252,18 @@ async def get_project_client(
11131252
workspace_id=workspace_id,
11141253
):
11151254
logger.debug("Using resolved workspace for cloud project routing")
1116-
async with get_client(
1117-
project_name=project_for_api,
1118-
workspace=workspace_id,
1119-
) as client:
1120-
active_project = await get_active_project(client, project_for_api, context)
1121-
yield client, active_project
1255+
permalink_context = (
1256+
workspace_permalink_context(active_ws.slug, active_ws.workspace_type)
1257+
if active_ws is not None
1258+
else nullcontext()
1259+
)
1260+
with permalink_context:
1261+
async with get_client(
1262+
project_name=project_for_api,
1263+
workspace=workspace_id,
1264+
) as client:
1265+
active_project = await get_active_project(client, project_for_api, context)
1266+
yield client, active_project
11221267
return
11231268

11241269
# Step 4: Local routing (default)

src/basic_memory/mcp/tools/build_context.py

Lines changed: 6 additions & 2 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-
detect_project_from_url_prefix,
12+
detect_project_from_memory_url_prefix,
1313
get_project_client,
1414
resolve_project_and_path,
1515
)
@@ -211,7 +211,11 @@ async def build_context(
211211
"""
212212
# Detect project from memory URL prefix before routing
213213
if project is None:
214-
detected = detect_project_from_url_prefix(url, ConfigManager().config)
214+
detected = await detect_project_from_memory_url_prefix(
215+
url,
216+
ConfigManager().config,
217+
context=context,
218+
)
215219
if detected:
216220
project = detected
217221

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from pydantic import AliasChoices, Field
88

99
from basic_memory.config import ConfigManager
10-
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
10+
from basic_memory.mcp.project_context import (
11+
detect_project_from_memory_url_prefix,
12+
get_project_client,
13+
resolve_project_and_path,
14+
)
1115
from basic_memory.mcp.server import mcp
1216

1317

@@ -236,7 +240,11 @@ async def delete_note(
236240
# where "research" is a directory, not a project name
237241
# Outcome: project is set from the URL prefix, routing goes to the correct project
238242
if project is None and identifier.strip().startswith("memory://"):
239-
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
243+
detected = await detect_project_from_memory_url_prefix(
244+
identifier,
245+
ConfigManager().config,
246+
context=context,
247+
)
240248
if detected:
241249
project = detected
242250

@@ -253,11 +261,17 @@ async def delete_note(
253261

254262
# Use typed KnowledgeClient for API calls
255263
knowledge_client = KnowledgeClient(client, active_project.external_id)
264+
_, target_identifier, _ = await resolve_project_and_path(
265+
client,
266+
identifier,
267+
active_project.name,
268+
context,
269+
)
256270

257271
# Handle directory deletes
258272
if is_directory:
259273
try:
260-
result = await knowledge_client.delete_directory(identifier)
274+
result = await knowledge_client.delete_directory(target_identifier)
261275
if output_format == "json":
262276
return {
263277
"deleted": result.failed_deletes == 0,
@@ -339,7 +353,7 @@ async def delete_note(
339353
note_file_path = None
340354
try:
341355
# Resolve identifier to entity ID
342-
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
356+
entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True)
343357
if output_format == "json":
344358
entity = await knowledge_client.get_entity(entity_id)
345359
note_title = entity.title

0 commit comments

Comments
 (0)