Skip to content

Commit df5e8d8

Browse files
authored
fix(mcp): centralize workspace permalink routing (#808)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 831dc1e commit df5e8d8

13 files changed

Lines changed: 993 additions & 96 deletions

src/basic_memory/mcp/async_client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,15 @@ def _build_timeout() -> Timeout:
3737

3838
def _asgi_client(timeout: Timeout) -> AsyncClient:
3939
"""Create a local ASGI client."""
40+
from basic_memory.workspace_context import workspace_permalink_headers
41+
4042
return AsyncClient(
41-
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
43+
transport=ASGITransport(app=fastapi_app),
44+
base_url="http://test",
45+
timeout=timeout,
46+
# Local ASGI calls still cross the HTTP boundary, so request handlers need
47+
# the same workspace permalink metadata that cloud proxy calls receive.
48+
headers=workspace_permalink_headers(),
4249
)
4350

4451

src/basic_memory/mcp/project_context.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
from basic_memory.schemas.project_info import ProjectItem, ProjectList
3030
from basic_memory.schemas.v2 import ProjectResolveResponse
3131
from basic_memory.schemas.memory import memory_url_path
32-
from basic_memory.utils import generate_permalink, normalize_project_reference
32+
from basic_memory.utils import (
33+
build_qualified_permalink_reference,
34+
generate_permalink,
35+
normalize_project_reference,
36+
)
3337
from basic_memory.workspace_context import (
3438
current_workspace_permalink_context,
3539
workspace_permalink_context,
@@ -417,14 +421,20 @@ def _unqualified_project_identifier(identifier: str) -> str:
417421
return project_identifier
418422

419423

420-
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
421-
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
422-
if not identifier.strip().startswith("memory://"):
423-
return None
424+
def _identifier_path(identifier: str) -> str:
425+
"""Return the routable path portion of a raw identifier or memory URL."""
426+
stripped = identifier.strip()
427+
return memory_url_path(stripped) if stripped.startswith("memory://") else stripped
424428

425-
normalized = normalize_project_reference(memory_url_path(identifier))
429+
430+
def _split_workspace_identifier_segments(identifier: str) -> tuple[str, str, str] | None:
431+
"""Split ``<workspace>/<project>/<path>`` identifiers into route segments."""
432+
normalized = normalize_project_reference(_identifier_path(identifier)).strip("/")
426433
parts = normalized.split("/", 2)
427434
if len(parts) != 3:
435+
# Trigger: two-segment identifiers such as `workspace/project` or `project/path`.
436+
# Why: without a remainder, the shape is ambiguous with existing project-prefix routing.
437+
# Outcome: fall through so the normal project-prefix/default-project resolver decides.
428438
return None
429439

430440
workspace_slug, project_identifier, remainder = parts
@@ -433,6 +443,14 @@ def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str
433443
return workspace_slug, project_identifier, remainder
434444

435445

446+
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
447+
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
448+
if not identifier.strip().startswith("memory://"):
449+
return None
450+
451+
return _split_workspace_identifier_segments(identifier)
452+
453+
436454
def _canonical_memory_path_for_workspace(
437455
*,
438456
workspace_slug: str,
@@ -448,10 +466,14 @@ def _canonical_memory_path_for_workspace(
448466
# Trigger: a caller supplied a workspace-qualified memory URL.
449467
# Why: the first two path segments are the global route, even for Personal.
450468
# Outcome: lookups preserve the complete workspace/project canonical permalink.
451-
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
452469
if not normalized_remainder:
453-
return prefix
454-
return f"{prefix}/{normalized_remainder}"
470+
normalized_remainder = project_permalink
471+
return build_qualified_permalink_reference(
472+
project_permalink,
473+
normalized_remainder,
474+
include_project=True,
475+
workspace_permalink=workspace_slug,
476+
)
455477

456478

457479
def _canonical_memory_path_for_active_route(
@@ -528,6 +550,27 @@ async def resolve_workspace_qualified_memory_url(
528550
if segments is None:
529551
return None
530552

553+
return await _resolve_workspace_segments(identifier, segments, context=context)
554+
555+
556+
async def resolve_workspace_qualified_identifier(
557+
identifier: str,
558+
context: Optional[Context] = None,
559+
) -> WorkspaceMemoryUrlResolution | None:
560+
"""Resolve a workspace-qualified permalink or memory URL against accessible workspaces."""
561+
segments = _split_workspace_identifier_segments(identifier)
562+
if segments is None:
563+
return None
564+
565+
return await _resolve_workspace_segments(identifier, segments, context=context)
566+
567+
568+
async def _resolve_workspace_segments(
569+
identifier: str,
570+
segments: tuple[str, str, str],
571+
context: Optional[Context] = None,
572+
) -> WorkspaceMemoryUrlResolution | None:
573+
"""Resolve parsed workspace/project/path segments against accessible workspaces."""
531574
workspace_slug, project_identifier, remainder = segments
532575
index = await _ensure_workspace_project_index(context=context)
533576
workspace = next(
@@ -1278,17 +1321,51 @@ async def detect_project_from_memory_url_prefix(
12781321
if not identifier.strip().startswith("memory://"):
12791322
return None
12801323

1324+
return await detect_project_from_identifier_prefix(identifier, config, context=context)
1325+
1326+
1327+
async def detect_project_from_identifier_prefix(
1328+
identifier: str,
1329+
config: BasicMemoryConfig,
1330+
context: Optional[Context] = None,
1331+
) -> Optional[str]:
1332+
"""Resolve a project from a plain permalink, memory URL, or workspace route prefix."""
12811333
local_project = detect_project_from_url_prefix(identifier, config)
12821334
if local_project is not None:
12831335
return local_project
12841336

1337+
normalized_identifier = normalize_project_reference(_identifier_path(identifier)).strip("/")
1338+
if "/" not in normalized_identifier:
1339+
# Trigger: plain text search query or single-segment title/permalink.
1340+
# Why: cloud project discovery can build a workspace index; only path-shaped
1341+
# identifiers carry enough structure to justify that cost.
1342+
# Outcome: keep unqualified search/title input on the active/default project route.
1343+
return None
1344+
12851345
if _cloud_workspace_discovery_available(config):
1286-
resolution = await resolve_workspace_qualified_memory_url(
1346+
workspace_resolution = await resolve_workspace_qualified_identifier(
12871347
identifier,
12881348
context=context,
12891349
)
1290-
if resolution is not None:
1291-
return resolution.project_identifier
1350+
if workspace_resolution is not None:
1351+
return workspace_resolution.project_identifier
1352+
1353+
project_prefix, _ = _split_project_prefix(normalized_identifier)
1354+
if project_prefix is None:
1355+
return None
1356+
1357+
try:
1358+
project_resolution = await resolve_workspace_project_identifier(
1359+
project_prefix,
1360+
context=context,
1361+
)
1362+
except ValueError as exc:
1363+
message = str(exc).lower()
1364+
if "not found" in message or "no accessible workspaces" in message:
1365+
return None
1366+
raise
1367+
1368+
return project_resolution.qualified_name
12921369

12931370
return None
12941371

src/basic_memory/mcp/tools/read_note.py

Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@
1111

1212
from basic_memory.config import ConfigManager
1313
from basic_memory.mcp.project_context import (
14-
detect_project_from_memory_url_prefix,
14+
detect_project_from_identifier_prefix,
1515
get_project_client,
1616
resolve_project_and_path,
1717
)
1818
from basic_memory.mcp.server import mcp
1919
from basic_memory.mcp.tools.search import search_notes
2020
from basic_memory.schemas.memory import memory_url_path
21-
from basic_memory.utils import generate_permalink, validate_project_path
22-
from basic_memory.workspace_context import current_workspace_permalink_context
21+
from basic_memory.utils import validate_project_path
2322

2423

2524
def _is_exact_title_match(identifier: str, title: str) -> bool:
@@ -131,10 +130,10 @@ async def read_note(
131130
If the exact note isn't found, this tool provides helpful suggestions
132131
including related notes, search commands, and note creation templates.
133132
"""
134-
# Detect project from memory URL prefix before routing.
133+
# Detect project from a memory URL or permalink prefix before routing.
135134
# project_id routes by external UUID, so it bypasses URL discovery entirely.
136135
if project is None and project_id is None:
137-
detected = await detect_project_from_memory_url_prefix(
136+
detected = await detect_project_from_identifier_prefix(
138137
identifier,
139138
ConfigManager().config,
140139
context=context,
@@ -278,51 +277,25 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
278277
value = item.get("file_path")
279278
return str(value) if value else None
280279

281-
def _legacy_workspace_unqualified_path(path: str) -> str | None:
282-
workspace_context = current_workspace_permalink_context()
283-
if workspace_context is None:
284-
return None
285-
286-
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
287-
project_prefix = active_project.permalink
288-
qualified_prefix = f"{workspace_prefix}/{project_prefix}"
289-
normalized_path = path.strip("/")
290-
if normalized_path == qualified_prefix:
291-
return project_prefix
292-
if normalized_path.startswith(f"{qualified_prefix}/"):
293-
return f"{project_prefix}/{normalized_path.removeprefix(f'{qualified_prefix}/')}"
294-
return None
295-
296-
direct_lookup_paths = [entity_path]
297-
legacy_path = _legacy_workspace_unqualified_path(entity_path)
298-
if legacy_path and legacy_path not in direct_lookup_paths:
299-
# Trigger: existing cloud rows may still use project-prefixed permalinks.
300-
# Why: new workspace-qualified IDs should read old notes without a re-sync.
301-
# Outcome: try the legacy path after the canonical workspace path misses.
302-
direct_lookup_paths.append(legacy_path)
303-
304-
for direct_lookup_path in direct_lookup_paths:
305-
try:
306-
# Try to resolve identifier to entity ID
307-
entity_id = await knowledge_client.resolve_entity(
308-
direct_lookup_path, strict=True
309-
)
280+
try:
281+
# Try to resolve identifier to entity ID
282+
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
310283

311-
# Fetch content using entity ID
312-
response = await resource_client.read(entity_id)
284+
# Fetch content using entity ID
285+
response = await resource_client.read(entity_id)
313286

314-
# If successful, return the content
315-
if response.status_code == 200:
316-
logger.info(
317-
"Returning read_note result from resource: {path}",
318-
path=direct_lookup_path,
319-
)
320-
if output_format == "json":
321-
return await _read_json_payload(entity_id)
322-
return response.text
323-
except Exception as e: # pragma: no cover
324-
logger.info(f"Direct lookup failed for '{direct_lookup_path}': {e}")
325-
# Continue to alternate direct lookup paths, then fallback methods
287+
# If successful, return the content
288+
if response.status_code == 200:
289+
logger.info(
290+
"Returning read_note result from resource: {path}",
291+
path=entity_path,
292+
)
293+
if output_format == "json":
294+
return await _read_json_payload(entity_id)
295+
return response.text
296+
except Exception as e: # pragma: no cover
297+
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
298+
# Continue to fallback methods
326299

327300
# Fallback 1: Try title search via API
328301
logger.info(f"Search title for: {identifier}")

src/basic_memory/mcp/tools/search.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
from pydantic import AliasChoices, BeforeValidator, Field
1212

1313
from basic_memory.config import ConfigManager
14-
from basic_memory.utils import coerce_dict, coerce_list
14+
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list
1515
from basic_memory.mcp.container import get_container
1616
from basic_memory.mcp.project_context import (
17-
detect_project_from_memory_url_prefix,
17+
detect_project_from_identifier_prefix,
1818
get_project_client,
1919
resolve_project_and_path,
2020
)
@@ -402,11 +402,12 @@ def _qualify_permalink_for_project(permalink: object, project: str | None) -> ob
402402
return normalized_permalink
403403

404404
workspace_slug, project_permalink = qualified_project.split("/", 1)
405-
if normalized_permalink == project_permalink or normalized_permalink.startswith(
406-
f"{project_permalink}/"
407-
):
408-
return f"{workspace_slug}/{normalized_permalink}"
409-
return f"{qualified_project}/{normalized_permalink}"
405+
return build_canonical_permalink(
406+
project_permalink,
407+
normalized_permalink,
408+
include_project=True,
409+
workspace_permalink=workspace_slug,
410+
)
410411

411412

412413
def _qualify_results_for_project(
@@ -805,10 +806,10 @@ async def search_notes(
805806
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
806807
query = remainder or None
807808

808-
# Detect project from memory URL prefix before routing.
809+
# Detect project from a memory URL or permalink prefix before routing.
809810
# project_id routes by external UUID, so it bypasses URL discovery entirely.
810811
if project is None and project_id is None and query is not None:
811-
detected = await detect_project_from_memory_url_prefix(
812+
detected = await detect_project_from_identifier_prefix(
812813
query,
813814
ConfigManager().config,
814815
context=context,

src/basic_memory/mcp/tools/utils.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any, Optional
99

1010
import logfire
11-
from httpx import Response, URL, AsyncClient, HTTPStatusError
11+
from httpx import Response, URL, AsyncClient, HTTPStatusError, Headers
1212
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
1313
from httpx._types import (
1414
RequestContent,
@@ -58,6 +58,22 @@ def _transport_error_span_attrs(exc: Exception) -> dict[str, Any]:
5858
}
5959

6060

61+
def _request_headers(headers: HeaderTypes | None) -> HeaderTypes | None:
62+
"""Merge request-local workspace permalink headers into outbound API calls."""
63+
from basic_memory.workspace_context import workspace_permalink_headers
64+
65+
workspace_headers = workspace_permalink_headers()
66+
if not workspace_headers:
67+
return headers
68+
69+
if headers is None:
70+
return workspace_headers
71+
72+
merged_headers = Headers(headers)
73+
merged_headers.update(workspace_headers)
74+
return merged_headers
75+
76+
6177
def get_error_message(
6278
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
6379
) -> str:
@@ -224,7 +240,7 @@ async def call_get(
224240
response = await client.get(
225241
url,
226242
params=params,
227-
headers=headers,
243+
headers=_request_headers(headers),
228244
cookies=cookies,
229245
auth=auth,
230246
follow_redirects=follow_redirects,
@@ -328,7 +344,7 @@ async def call_put(
328344
files=files,
329345
json=json,
330346
params=params,
331-
headers=headers,
347+
headers=_request_headers(headers),
332348
cookies=cookies,
333349
auth=auth,
334350
follow_redirects=follow_redirects,
@@ -432,7 +448,7 @@ async def call_patch(
432448
files=files,
433449
json=json,
434450
params=params,
435-
headers=headers,
451+
headers=_request_headers(headers),
436452
cookies=cookies,
437453
auth=auth,
438454
follow_redirects=follow_redirects,
@@ -542,7 +558,7 @@ async def call_post(
542558
files=files,
543559
json=json,
544560
params=params,
545-
headers=headers,
561+
headers=_request_headers(headers),
546562
cookies=cookies,
547563
auth=auth,
548564
follow_redirects=follow_redirects,
@@ -667,7 +683,7 @@ async def call_delete(
667683
response = await client.delete(
668684
url=url,
669685
params=params,
670-
headers=headers,
686+
headers=_request_headers(headers),
671687
cookies=cookies,
672688
auth=auth,
673689
follow_redirects=follow_redirects,

0 commit comments

Comments
 (0)