mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix(mcp): route workspace-qualified memory urls (#790)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
|
||||
@@ -29,6 +30,12 @@ import logfire
|
||||
from basic_memory.config import init_api_logging
|
||||
from basic_memory.services.exceptions import EntityAlreadyExistsError
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.workspace_context import (
|
||||
WORKSPACE_SLUG_HEADER,
|
||||
WORKSPACE_TYPE_HEADER,
|
||||
workspace_permalink_context_validation_error,
|
||||
workspace_permalink_context,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -87,6 +94,32 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def workspace_permalink_context_middleware(request: Request, call_next):
|
||||
"""Populate workspace permalink context from request headers."""
|
||||
workspace_slug = request.headers.get(WORKSPACE_SLUG_HEADER)
|
||||
workspace_type = request.headers.get(WORKSPACE_TYPE_HEADER)
|
||||
|
||||
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
|
||||
if validation_error is not None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": validation_error},
|
||||
)
|
||||
|
||||
if not workspace_slug:
|
||||
return await call_next(request)
|
||||
|
||||
# ContextVar state remains active across the awaited downstream handler while
|
||||
# this context manager is open, so entity creation can see request metadata.
|
||||
with workspace_permalink_context(
|
||||
workspace_slug=workspace_slug,
|
||||
workspace_type=workspace_type,
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Include v2 routers FIRST (more specific paths must match before /{project} catch-all)
|
||||
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
|
||||
@@ -146,4 +179,7 @@ async def exception_handler(request, exc): # pragma: no cover
|
||||
error_type=type(exc).__name__,
|
||||
error=str(exc),
|
||||
)
|
||||
return await http_exception_handler(request, HTTPException(status_code=500, detail=str(exc)))
|
||||
return await http_exception_handler(
|
||||
request,
|
||||
HTTPException(status_code=500, detail="Internal server error"),
|
||||
)
|
||||
|
||||
@@ -94,9 +94,12 @@ async def _cloud_client(
|
||||
workspace: Optional[str] = None,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""Create a cloud proxy client with resolved credentials."""
|
||||
from basic_memory.workspace_context import workspace_permalink_headers
|
||||
|
||||
token = await _resolve_cloud_token(config)
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
headers.update(workspace_permalink_headers())
|
||||
if workspace:
|
||||
headers["X-Workspace-ID"] = workspace
|
||||
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
|
||||
|
||||
@@ -9,7 +9,7 @@ compatibility with existing MCP tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
|
||||
from uuid import UUID
|
||||
@@ -30,6 +30,10 @@ from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import generate_permalink, normalize_project_reference
|
||||
from basic_memory.workspace_context import (
|
||||
current_workspace_permalink_context,
|
||||
workspace_permalink_context,
|
||||
)
|
||||
|
||||
# --- Workspace provider injection ---
|
||||
# Mirrors the set_client_factory() pattern in async_client.py.
|
||||
@@ -62,6 +66,18 @@ class WorkspaceProjectIndex:
|
||||
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceMemoryUrlResolution:
|
||||
"""Resolved workspace/project route for a workspace-qualified memory URL."""
|
||||
|
||||
entry: WorkspaceProjectEntry
|
||||
canonical_path: str
|
||||
|
||||
@property
|
||||
def project_identifier(self) -> str:
|
||||
return self.entry.qualified_name
|
||||
|
||||
|
||||
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
|
||||
"""Override workspace discovery (for cloud app, testing, etc)."""
|
||||
global _workspace_provider
|
||||
@@ -390,6 +406,130 @@ def _unqualified_project_identifier(identifier: str) -> str:
|
||||
return project_identifier
|
||||
|
||||
|
||||
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
|
||||
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
|
||||
if not identifier.strip().startswith("memory://"):
|
||||
return None
|
||||
|
||||
normalized = normalize_project_reference(memory_url_path(identifier))
|
||||
parts = normalized.split("/", 2)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
workspace_slug, project_identifier, remainder = parts
|
||||
if not workspace_slug or not project_identifier or not remainder:
|
||||
return None
|
||||
return workspace_slug, project_identifier, remainder
|
||||
|
||||
|
||||
def _canonical_memory_path_for_workspace(
|
||||
*,
|
||||
workspace_slug: str,
|
||||
workspace_type: str,
|
||||
project_permalink: str,
|
||||
remainder: str,
|
||||
include_project: bool,
|
||||
) -> str:
|
||||
"""Return the stored canonical path for a workspace-qualified memory URL."""
|
||||
normalized_remainder = remainder.strip("/")
|
||||
if workspace_type == "organization":
|
||||
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
|
||||
elif workspace_type == "personal":
|
||||
prefix = project_permalink if include_project else ""
|
||||
else:
|
||||
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
|
||||
|
||||
if not prefix:
|
||||
return normalized_remainder
|
||||
if not normalized_remainder:
|
||||
return prefix
|
||||
return f"{prefix}/{normalized_remainder}"
|
||||
|
||||
|
||||
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
|
||||
"""Return True when workspace discovery can be used without forcing local routing."""
|
||||
from basic_memory.mcp.async_client import (
|
||||
_explicit_routing,
|
||||
_force_local_mode,
|
||||
is_factory_mode,
|
||||
)
|
||||
|
||||
if _explicit_routing() and _force_local_mode():
|
||||
return False
|
||||
|
||||
# Trigger: local project config is present even though cloud credentials are saved.
|
||||
# Why: existing local `memory://...` URLs must not depend on workspace discovery.
|
||||
# Outcome: only factory, explicit cloud, or cloud-only sessions attempt discovery here.
|
||||
return (
|
||||
is_factory_mode()
|
||||
or (_explicit_routing() and not _force_local_mode())
|
||||
or (not config.projects and has_cloud_credentials(config))
|
||||
)
|
||||
|
||||
|
||||
async def resolve_workspace_qualified_memory_url(
|
||||
identifier: str,
|
||||
context: Optional[Context] = None,
|
||||
) -> WorkspaceMemoryUrlResolution | None:
|
||||
"""Resolve a workspace-qualified memory URL against accessible workspaces."""
|
||||
segments = _split_workspace_memory_url_segments(identifier)
|
||||
if segments is None:
|
||||
return None
|
||||
|
||||
workspace_slug, project_identifier, remainder = segments
|
||||
index = await _ensure_workspace_project_index(context=context)
|
||||
workspace = next(
|
||||
(item for item in index.workspaces if item.slug.casefold() == workspace_slug.casefold()),
|
||||
None,
|
||||
)
|
||||
if workspace is None:
|
||||
return None
|
||||
|
||||
project_permalink = generate_permalink(project_identifier)
|
||||
matches = [
|
||||
entry
|
||||
for entry in index.entries_by_permalink.get(project_permalink, ())
|
||||
if entry.workspace.tenant_id == workspace.tenant_id
|
||||
]
|
||||
if not matches:
|
||||
if any(
|
||||
failed_workspace.tenant_id == workspace.tenant_id
|
||||
for failed_workspace in index.failed_workspaces
|
||||
):
|
||||
raise ValueError(
|
||||
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
|
||||
"could not be loaded. Retry after workspace discovery recovers."
|
||||
)
|
||||
|
||||
# Trigger: first segment matches a workspace slug but the second does not
|
||||
# match a project in that workspace.
|
||||
# Why: workspace-qualified URLs require both route segments to match; otherwise
|
||||
# existing project-prefixed URLs like `memory://main/notes/foo` can collide
|
||||
# with a workspace slug named `main`.
|
||||
# Outcome: treat this as not workspace-qualified and let the caller use
|
||||
# the existing project-prefix/default-project resolver.
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
details = ", ".join(
|
||||
f"{entry.qualified_name} ({entry.project.external_id})" for entry in matches
|
||||
)
|
||||
raise ValueError(
|
||||
f"Project '{project_identifier}' matched multiple projects in workspace "
|
||||
f"'{workspace.name}' ({workspace.slug}). Project permalinks must be unique. "
|
||||
f"Matches: {details}"
|
||||
)
|
||||
|
||||
entry = matches[0]
|
||||
canonical_path = _canonical_memory_path_for_workspace(
|
||||
workspace_slug=entry.workspace.slug,
|
||||
workspace_type=entry.workspace.workspace_type,
|
||||
project_permalink=entry.project.permalink,
|
||||
remainder=remainder,
|
||||
include_project=ConfigManager().config.permalinks_include_project,
|
||||
)
|
||||
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
|
||||
|
||||
|
||||
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
|
||||
"""Format qualified project choices for collision errors."""
|
||||
return " or ".join(entry.qualified_name for entry in entries)
|
||||
@@ -608,6 +748,15 @@ async def resolve_workspace_project_identifier(
|
||||
f"Project '{project_identifier}' was not found in workspace "
|
||||
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
details = ", ".join(
|
||||
f"{entry.qualified_name} ({entry.project.external_id})" for entry in matches
|
||||
)
|
||||
raise ValueError(
|
||||
f"Project '{project_identifier}' matched multiple projects in workspace "
|
||||
f"'{workspace.name}' ({workspace.slug}). Project permalinks must be unique. "
|
||||
f"Matches: {details}"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
matches = index.entries_by_permalink.get(project_permalink, ())
|
||||
@@ -680,6 +829,56 @@ async def _default_workspace_project_entry(
|
||||
return default_entries[0] if default_entries else None
|
||||
|
||||
|
||||
async def _workspace_metadata_by_tenant_id(
|
||||
tenant_id: str,
|
||||
context: Optional[Context] = None,
|
||||
) -> WorkspaceInfo | None:
|
||||
"""Return non-index workspace metadata for a configured tenant id."""
|
||||
cached_workspace = await _get_cached_active_workspace(context)
|
||||
if cached_workspace and cached_workspace.tenant_id == tenant_id:
|
||||
return cached_workspace
|
||||
|
||||
if cached_workspace and context:
|
||||
# Trigger: the configured workspace_id differs from cached workspace metadata.
|
||||
# Why: tenant_id routes the request, but stale workspace slug/type would corrupt
|
||||
# memory URL normalization and canonical permalink headers.
|
||||
# Outcome: drop stale metadata and route without permalink decoration.
|
||||
await context.set_state("active_workspace", None)
|
||||
|
||||
if context:
|
||||
cached_raw = await context.get_state("available_workspaces")
|
||||
if isinstance(cached_raw, list):
|
||||
for item in cached_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
workspace = WorkspaceInfo.model_validate(item)
|
||||
if workspace.tenant_id == tenant_id:
|
||||
return workspace
|
||||
|
||||
if _workspace_provider is not None:
|
||||
# Trigger: the hosting runtime can provide workspace metadata directly.
|
||||
# Why: configured workspace_id is already sufficient for tenant routing, but
|
||||
# canonical organization permalinks also need slug/type context.
|
||||
# Outcome: use the injected runtime seam without loading the workspace project index.
|
||||
workspace = next(
|
||||
(
|
||||
workspace
|
||||
for workspace in await get_available_workspaces(context=context)
|
||||
if workspace.tenant_id == tenant_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if workspace is None:
|
||||
raise ValueError(
|
||||
f"Configured workspace_id '{tenant_id}' was not returned by the workspace "
|
||||
"metadata provider. Reconfigure the project workspace or retry after "
|
||||
"workspace metadata recovers."
|
||||
)
|
||||
return workspace
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_workspace_parameter(
|
||||
workspace: Optional[str] = None,
|
||||
context: Optional[Context] = None,
|
||||
@@ -856,13 +1055,57 @@ async def resolve_project_and_path(
|
||||
return active_project, identifier, False
|
||||
|
||||
normalized_path = normalize_project_reference(memory_url_path(identifier))
|
||||
cached_project = await _get_cached_active_project(context)
|
||||
cached_workspace = await _get_cached_active_workspace(context)
|
||||
if cached_project and cached_workspace:
|
||||
workspace_prefix = generate_permalink(cached_workspace.slug)
|
||||
qualified_prefix = f"{workspace_prefix}/{cached_project.permalink}"
|
||||
if normalized_path == qualified_prefix or normalized_path.startswith(
|
||||
f"{qualified_prefix}/"
|
||||
):
|
||||
remainder = (
|
||||
""
|
||||
if normalized_path == qualified_prefix
|
||||
else normalized_path.removeprefix(f"{qualified_prefix}/")
|
||||
)
|
||||
resolved_path = _canonical_memory_path_for_workspace(
|
||||
workspace_slug=cached_workspace.slug,
|
||||
workspace_type=cached_workspace.workspace_type,
|
||||
project_permalink=cached_project.permalink,
|
||||
remainder=remainder,
|
||||
include_project=bool(include_project),
|
||||
)
|
||||
return cached_project, resolved_path, True
|
||||
|
||||
workspace_context = current_workspace_permalink_context()
|
||||
if workspace_context and project:
|
||||
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
|
||||
project_permalink = generate_permalink(_unqualified_project_identifier(project))
|
||||
qualified_prefix = f"{workspace_prefix}/{project_permalink}"
|
||||
if normalized_path == qualified_prefix or normalized_path.startswith(
|
||||
f"{qualified_prefix}/"
|
||||
):
|
||||
active_project = await get_active_project(client, project, context, headers)
|
||||
remainder = (
|
||||
""
|
||||
if normalized_path == qualified_prefix
|
||||
else normalized_path.removeprefix(f"{qualified_prefix}/")
|
||||
)
|
||||
resolved_path = _canonical_memory_path_for_workspace(
|
||||
workspace_slug=workspace_context.workspace_slug,
|
||||
workspace_type=workspace_context.workspace_type,
|
||||
project_permalink=project_permalink,
|
||||
remainder=remainder,
|
||||
include_project=bool(include_project),
|
||||
)
|
||||
return active_project, resolved_path, True
|
||||
|
||||
project_prefix, remainder = _split_project_prefix(normalized_path)
|
||||
include_project = config.permalinks_include_project
|
||||
# Trigger: memory URL begins with a potential project segment
|
||||
# Why: allow project-scoped memory URLs without requiring a separate project parameter
|
||||
# Outcome: attempt to resolve the prefix as a project and route to it
|
||||
if project_prefix:
|
||||
cached_project = await _get_cached_active_project(context)
|
||||
if cached_project and _project_matches_identifier(cached_project, project_prefix):
|
||||
resolved_project = await resolve_project_parameter(project_prefix, context=context)
|
||||
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
|
||||
@@ -975,6 +1218,30 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -
|
||||
return None
|
||||
|
||||
|
||||
async def detect_project_from_memory_url_prefix(
|
||||
identifier: str,
|
||||
config: BasicMemoryConfig,
|
||||
context: Optional[Context] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a project from a memory URL prefix, including workspace-qualified URLs."""
|
||||
if not identifier.strip().startswith("memory://"):
|
||||
return None
|
||||
|
||||
local_project = detect_project_from_url_prefix(identifier, config)
|
||||
if local_project is not None:
|
||||
return local_project
|
||||
|
||||
if _cloud_workspace_discovery_available(config):
|
||||
resolution = await resolve_workspace_qualified_memory_url(
|
||||
identifier,
|
||||
context=context,
|
||||
)
|
||||
if resolution is not None:
|
||||
return resolution.project_identifier
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_project_client(
|
||||
project: Optional[str] = None,
|
||||
@@ -1091,6 +1358,7 @@ async def get_project_client(
|
||||
if project_entry and project_entry.workspace_id:
|
||||
# Per-project config stores the cloud tenant id directly
|
||||
workspace_id = project_entry.workspace_id
|
||||
active_ws = await _workspace_metadata_by_tenant_id(workspace_id, context=context)
|
||||
else:
|
||||
resolved_entry = cloud_default_entry
|
||||
if resolved_entry is None or not _project_matches_identifier(
|
||||
@@ -1113,12 +1381,18 @@ async def get_project_client(
|
||||
workspace_id=workspace_id,
|
||||
):
|
||||
logger.debug("Using resolved workspace for cloud project routing")
|
||||
async with get_client(
|
||||
project_name=project_for_api,
|
||||
workspace=workspace_id,
|
||||
) as client:
|
||||
active_project = await get_active_project(client, project_for_api, context)
|
||||
yield client, active_project
|
||||
permalink_context = (
|
||||
workspace_permalink_context(active_ws.slug, active_ws.workspace_type)
|
||||
if active_ws is not None
|
||||
else nullcontext()
|
||||
)
|
||||
with permalink_context:
|
||||
async with get_client(
|
||||
project_name=project_for_api,
|
||||
workspace=workspace_id,
|
||||
) as client:
|
||||
active_project = await get_active_project(client, project_for_api, context)
|
||||
yield client, active_project
|
||||
return
|
||||
|
||||
# Step 4: Local routing (default)
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -209,9 +209,14 @@ async def build_context(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
if project is None:
|
||||
detected = detect_project_from_url_prefix(url, ConfigManager().config)
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# project_id routes by external UUID, so it bypasses URL discovery entirely.
|
||||
if project is None and project_id is None:
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
url,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
|
||||
@@ -7,8 +7,15 @@ from mcp.server.fastmcp.exceptions import ToolError
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from basic_memory.utils import generate_permalink, normalize_project_reference
|
||||
from basic_memory.workspace_context import current_workspace_permalink_context
|
||||
|
||||
|
||||
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
||||
@@ -148,6 +155,32 @@ delete_note("{project}", "correct-identifier-from-search")
|
||||
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
|
||||
|
||||
|
||||
def _directory_path_for_delete(
|
||||
target_identifier: str,
|
||||
active_project: ProjectItem,
|
||||
*,
|
||||
include_project_prefix: bool,
|
||||
) -> str:
|
||||
"""Return the project-relative directory path expected by the delete API."""
|
||||
directory = normalize_project_reference(target_identifier).strip("/")
|
||||
project_permalink = active_project.permalink
|
||||
|
||||
route_prefixes: list[str] = []
|
||||
workspace_context = current_workspace_permalink_context()
|
||||
if workspace_context and workspace_context.should_prefix_permalinks:
|
||||
route_prefixes.append(
|
||||
f"{generate_permalink(workspace_context.workspace_slug)}/{project_permalink}"
|
||||
)
|
||||
if include_project_prefix:
|
||||
route_prefixes.append(project_permalink)
|
||||
|
||||
for route_prefix in route_prefixes:
|
||||
if directory.startswith(f"{route_prefix}/"):
|
||||
return directory.removeprefix(f"{route_prefix}/")
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Delete a note or directory by title, permalink, or path",
|
||||
annotations={"destructiveHint": True, "openWorldHint": False},
|
||||
@@ -231,12 +264,16 @@ async def delete_note(
|
||||
commands and alternative formats to try.
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
# Trigger: identifier starts with memory:// and no explicit project was provided
|
||||
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
|
||||
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
|
||||
# where "research" is a directory, not a project name
|
||||
# Outcome: project is set from the URL prefix, routing goes to the correct project
|
||||
if project is None and identifier.strip().startswith("memory://"):
|
||||
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
|
||||
if project is None and project_id is None and identifier.strip().startswith("memory://"):
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
identifier,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
@@ -253,11 +290,30 @@ async def delete_note(
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
_, target_identifier, is_memory_url = await resolve_project_and_path(
|
||||
client,
|
||||
identifier,
|
||||
active_project.name,
|
||||
context,
|
||||
)
|
||||
|
||||
# Handle directory deletes
|
||||
if is_directory:
|
||||
try:
|
||||
result = await knowledge_client.delete_directory(identifier)
|
||||
# Trigger: directory input was routed from a memory:// URL.
|
||||
# Why: resolve_project_and_path returns canonical permalinks, while
|
||||
# delete_directory filters by project-relative file_path prefixes.
|
||||
# Outcome: strip only the route prefix before calling the delete API.
|
||||
directory_identifier = (
|
||||
_directory_path_for_delete(
|
||||
target_identifier,
|
||||
active_project,
|
||||
include_project_prefix=ConfigManager().config.permalinks_include_project,
|
||||
)
|
||||
if is_memory_url
|
||||
else target_identifier
|
||||
)
|
||||
result = await knowledge_client.delete_directory(directory_identifier)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"deleted": result.failed_deletes == 0,
|
||||
@@ -339,7 +395,7 @@ delete_note("path/to/file.md")
|
||||
note_file_path = None
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True)
|
||||
if output_format == "json":
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
note_title = entity.title
|
||||
|
||||
@@ -9,9 +9,10 @@ from pydantic import AliasChoices, Field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
add_project_metadata,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.base import Entity
|
||||
@@ -288,12 +289,16 @@ async def edit_note(
|
||||
effective_replacements = expected_replacements if expected_replacements is not None else 1
|
||||
|
||||
# Detect project from memory URL prefix before routing
|
||||
# Trigger: identifier starts with memory:// and no explicit project was provided
|
||||
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
|
||||
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
|
||||
# where "research" is a directory, not a project name
|
||||
# Outcome: project is set from the URL prefix, routing goes to the correct project
|
||||
if project is None and identifier.strip().startswith("memory://"):
|
||||
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
|
||||
if project is None and project_id is None and identifier.strip().startswith("memory://"):
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
identifier,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
@@ -346,6 +351,12 @@ async def edit_note(
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
_, entity_identifier, _ = await resolve_project_and_path(
|
||||
client,
|
||||
identifier,
|
||||
active_project.name,
|
||||
context,
|
||||
)
|
||||
|
||||
file_created = False
|
||||
entity_id = ""
|
||||
@@ -353,7 +364,10 @@ async def edit_note(
|
||||
|
||||
# Try to resolve the entity; for append/prepend, create it if not found
|
||||
try:
|
||||
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
entity_id = await knowledge_client.resolve_entity(
|
||||
entity_identifier,
|
||||
strict=True,
|
||||
)
|
||||
except Exception as resolve_error:
|
||||
# Trigger: entity does not exist yet
|
||||
# Why: append/prepend can meaningfully create a new note from the content,
|
||||
|
||||
@@ -18,7 +18,7 @@ from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -217,9 +217,14 @@ async def read_content(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If path attempts path traversal
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
if project is None:
|
||||
detected = detect_project_from_url_prefix(path, ConfigManager().config)
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# project_id routes by external UUID, so it bypasses URL discovery entirely.
|
||||
if project is None and project_id is None:
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
path,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -33,10 +33,10 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
|
||||
If parsing fails or frontmatter is not a mapping, returns body unchanged and None.
|
||||
"""
|
||||
original_content = content
|
||||
if not content.startswith("---\n"):
|
||||
lines = content.splitlines(keepends=True)
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return original_content, None
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
closing_index = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
@@ -130,9 +130,14 @@ async def read_note(
|
||||
If the exact note isn't found, this tool provides helpful suggestions
|
||||
including related notes, search commands, and note creation templates.
|
||||
"""
|
||||
# Detect project from memory URL prefix before routing
|
||||
if project is None:
|
||||
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# project_id routes by external UUID, so it bypasses URL discovery entirely.
|
||||
if project is None and project_id is None:
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
identifier,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from basic_memory.config import ConfigManager
|
||||
from basic_memory.utils import coerce_dict, coerce_list
|
||||
from basic_memory.mcp.container import get_container
|
||||
from basic_memory.mcp.project_context import (
|
||||
detect_project_from_url_prefix,
|
||||
detect_project_from_memory_url_prefix,
|
||||
get_project_client,
|
||||
resolve_project_and_path,
|
||||
)
|
||||
@@ -551,9 +551,14 @@ async def search_notes(
|
||||
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
|
||||
query = remainder or None
|
||||
|
||||
# Detect project from memory URL prefix before routing
|
||||
if project is None and query is not None:
|
||||
detected = detect_project_from_url_prefix(query, ConfigManager().config)
|
||||
# Detect project from memory URL prefix before routing.
|
||||
# project_id routes by external UUID, so it bypasses URL discovery entirely.
|
||||
if project is None and project_id is None and query is not None:
|
||||
detected = await detect_project_from_memory_url_prefix(
|
||||
query,
|
||||
ConfigManager().config,
|
||||
context=context,
|
||||
)
|
||||
if detected:
|
||||
project = detected
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ from basic_memory.services.exceptions import (
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.utils import build_canonical_permalink
|
||||
from basic_memory.workspace_context import workspace_slug_for_canonical_permalinks
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -205,15 +206,20 @@ class EntityService(BaseService[EntityModel]):
|
||||
if self.app_config:
|
||||
include_project = self.app_config.permalinks_include_project
|
||||
|
||||
workspace_permalink = workspace_slug_for_canonical_permalinks()
|
||||
project_permalink = None
|
||||
# Trigger: project-prefixed permalinks are enabled
|
||||
# Why: we need the project slug to build the canonical permalink
|
||||
# Outcome: fetch and cache the project's permalink
|
||||
if include_project:
|
||||
# Trigger: project-prefixed permalinks are enabled, or organization workspace
|
||||
# context requires a complete workspace/project canonical permalink.
|
||||
# Why: project slug is the stable middle segment for globally addressable links.
|
||||
# Outcome: fetch and cache the project's permalink before building the canonical URL.
|
||||
if include_project or workspace_permalink:
|
||||
project_permalink = await self._get_project_permalink()
|
||||
|
||||
desired_permalink = build_canonical_permalink(
|
||||
project_permalink, file_path_str, include_project=include_project
|
||||
project_permalink,
|
||||
file_path_str,
|
||||
include_project=include_project,
|
||||
workspace_permalink=workspace_permalink,
|
||||
)
|
||||
|
||||
# Make unique if needed - enhanced to handle character conflicts
|
||||
|
||||
@@ -224,18 +224,43 @@ def build_canonical_permalink(
|
||||
project_permalink: Optional[str],
|
||||
file_path: Union[Path, str, PathLike],
|
||||
include_project: bool = True,
|
||||
*,
|
||||
workspace_permalink: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a canonical permalink, optionally prefixed with project slug.
|
||||
"""Build a canonical permalink, optionally prefixed with workspace/project slugs.
|
||||
|
||||
Args:
|
||||
project_permalink: URL-friendly project identifier (slug). If None, no prefix is added.
|
||||
file_path: Original file path or permalink-like string.
|
||||
include_project: When True, prefix with project slug.
|
||||
workspace_permalink: Optional URL-friendly workspace identifier. When provided,
|
||||
prefix the project-qualified permalink with this workspace slug.
|
||||
|
||||
Returns:
|
||||
Canonical permalink string.
|
||||
"""
|
||||
normalized_path = generate_permalink(file_path)
|
||||
normalized_workspace = generate_permalink(workspace_permalink) if workspace_permalink else None
|
||||
|
||||
if normalized_workspace:
|
||||
if not project_permalink:
|
||||
raise ValueError("workspace_permalink requires project_permalink")
|
||||
|
||||
normalized_project = generate_permalink(project_permalink)
|
||||
workspace_project_prefix = f"{normalized_workspace}/{normalized_project}"
|
||||
if normalized_path == workspace_project_prefix or normalized_path.startswith(
|
||||
f"{workspace_project_prefix}/"
|
||||
):
|
||||
return normalized_path
|
||||
|
||||
if normalized_path == normalized_project or normalized_path.startswith(
|
||||
f"{normalized_project}/"
|
||||
):
|
||||
project_path = normalized_path
|
||||
else:
|
||||
project_path = f"{normalized_project}/{normalized_path}"
|
||||
|
||||
return f"{normalized_workspace}/{project_path}"
|
||||
|
||||
if not include_project or not project_permalink:
|
||||
return normalized_path
|
||||
@@ -244,9 +269,11 @@ def build_canonical_permalink(
|
||||
if normalized_path == normalized_project or normalized_path.startswith(
|
||||
f"{normalized_project}/"
|
||||
):
|
||||
return normalized_path
|
||||
project_path = normalized_path
|
||||
else:
|
||||
project_path = f"{normalized_project}/{normalized_path}"
|
||||
|
||||
return f"{normalized_project}/{normalized_path}"
|
||||
return project_path
|
||||
|
||||
|
||||
def setup_logging(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Request-local workspace context for canonical permalink generation."""
|
||||
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
|
||||
WORKSPACE_SLUG_HEADER = "X-Basic-Memory-Workspace-Slug"
|
||||
WORKSPACE_TYPE_HEADER = "X-Basic-Memory-Workspace-Type"
|
||||
_WORKSPACE_SLUG_PATTERN = re.compile(r"^[a-z0-9_-]+$")
|
||||
_WORKSPACE_TYPES = {"personal", "organization"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePermalinkContext:
|
||||
"""Workspace metadata needed to build canonical organization permalinks."""
|
||||
|
||||
workspace_slug: str
|
||||
workspace_type: str
|
||||
|
||||
@property
|
||||
def should_prefix_permalinks(self) -> bool:
|
||||
return self.workspace_type == "organization" and bool(self.workspace_slug)
|
||||
|
||||
|
||||
_workspace_permalink_context: ContextVar[WorkspacePermalinkContext | None] = ContextVar(
|
||||
"basic_memory_workspace_permalink_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def current_workspace_permalink_context() -> WorkspacePermalinkContext | None:
|
||||
"""Return the active workspace permalink context, when one is set."""
|
||||
return _workspace_permalink_context.get()
|
||||
|
||||
|
||||
def validate_workspace_permalink_context_values(
|
||||
workspace_slug: str | None,
|
||||
workspace_type: str | None,
|
||||
) -> None:
|
||||
"""Validate workspace permalink metadata before it can affect stored permalinks."""
|
||||
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
|
||||
if validation_error is not None:
|
||||
raise ValueError(validation_error)
|
||||
|
||||
|
||||
def workspace_permalink_context_validation_error(
|
||||
workspace_slug: str | None,
|
||||
workspace_type: str | None,
|
||||
) -> str | None:
|
||||
"""Return the validation error for workspace permalink metadata, if any."""
|
||||
if bool(workspace_slug) != bool(workspace_type):
|
||||
return "workspace_slug and workspace_type must be provided together"
|
||||
|
||||
if not workspace_slug or not workspace_type:
|
||||
return None
|
||||
|
||||
if _WORKSPACE_SLUG_PATTERN.fullmatch(workspace_slug) is None:
|
||||
return f"{WORKSPACE_SLUG_HEADER} must match [a-z0-9_-]+"
|
||||
|
||||
if workspace_type not in _WORKSPACE_TYPES:
|
||||
allowed = ", ".join(sorted(_WORKSPACE_TYPES))
|
||||
return f"{WORKSPACE_TYPE_HEADER} must be one of: {allowed}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def workspace_permalink_context(
|
||||
workspace_slug: str | None,
|
||||
workspace_type: str | None,
|
||||
) -> Iterator[None]:
|
||||
"""Set request-local workspace permalink metadata.
|
||||
|
||||
Cloud can populate this per request without storing workspace metadata in
|
||||
local project config. The slug/type pair is all permalink generation needs.
|
||||
"""
|
||||
validate_workspace_permalink_context_values(workspace_slug, workspace_type)
|
||||
|
||||
if not workspace_slug or not workspace_type:
|
||||
yield
|
||||
return
|
||||
|
||||
token = _workspace_permalink_context.set(
|
||||
WorkspacePermalinkContext(
|
||||
workspace_slug=workspace_slug,
|
||||
workspace_type=workspace_type,
|
||||
)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_workspace_permalink_context.reset(token)
|
||||
|
||||
|
||||
def workspace_permalink_headers() -> dict[str, str]:
|
||||
"""Return HTTP headers for forwarding workspace permalink context."""
|
||||
context = current_workspace_permalink_context()
|
||||
if context is None:
|
||||
return {}
|
||||
|
||||
return {
|
||||
WORKSPACE_SLUG_HEADER: context.workspace_slug,
|
||||
WORKSPACE_TYPE_HEADER: context.workspace_type,
|
||||
}
|
||||
|
||||
|
||||
def workspace_slug_for_canonical_permalinks() -> str | None:
|
||||
"""Return the workspace slug when new permalinks should include it."""
|
||||
context = current_workspace_permalink_context()
|
||||
if context and context.should_prefix_permalinks:
|
||||
return context.workspace_slug
|
||||
return None
|
||||
Reference in New Issue
Block a user