Skip to content

Commit 9a3ac1d

Browse files
committed
fix(mcp): preserve legacy permalink routing
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 169e960 commit 9a3ac1d

6 files changed

Lines changed: 80 additions & 8 deletions

File tree

src/basic_memory/mcp/async_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def _asgi_client(timeout: Timeout) -> AsyncClient:
4343
transport=ASGITransport(app=fastapi_app),
4444
base_url="http://test",
4545
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.
4648
headers=workspace_permalink_headers(),
4749
)
4850

src/basic_memory/mcp/project_context.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,9 @@ def _split_workspace_identifier_segments(identifier: str) -> tuple[str, str, str
432432
normalized = normalize_project_reference(_identifier_path(identifier)).strip("/")
433433
parts = normalized.split("/", 2)
434434
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.
435438
return None
436439

437440
workspace_slug, project_identifier, remainder = parts
@@ -1331,6 +1334,14 @@ async def detect_project_from_identifier_prefix(
13311334
if local_project is not None:
13321335
return local_project
13331336

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+
13341345
if _cloud_workspace_discovery_available(config):
13351346
workspace_resolution = await resolve_workspace_qualified_identifier(
13361347
identifier,
@@ -1339,8 +1350,7 @@ async def detect_project_from_identifier_prefix(
13391350
if workspace_resolution is not None:
13401351
return workspace_resolution.project_identifier
13411352

1342-
normalized = normalize_project_reference(_identifier_path(identifier))
1343-
project_prefix, _ = _split_project_prefix(normalized)
1353+
project_prefix, _ = _split_project_prefix(normalized_identifier)
13441354
if project_prefix is None:
13451355
return None
13461356

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,

src/basic_memory/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,12 @@ def add_candidate(value: str | None) -> None:
386386
remainder = normalized_path.removeprefix(f"{normalized_project}/")
387387
add_candidate(remainder)
388388

389+
if not include_project and normalized_path.startswith(f"{normalized_project}/"):
390+
# Trigger: caller supplied `project/path` while legacy short permalinks are stored.
391+
# Why: routing uses the project prefix, but strict lookup still needs the short row.
392+
# Outcome: try `path` after the exact project-qualified candidate.
393+
add_candidate(normalized_path.removeprefix(f"{normalized_project}/"))
394+
389395
return candidates
390396

391397

tests/mcp/test_tool_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
call_put,
1414
get_error_message,
1515
)
16+
from basic_memory.workspace_context import (
17+
WORKSPACE_SLUG_HEADER,
18+
WORKSPACE_TYPE_HEADER,
19+
workspace_permalink_context,
20+
)
1621

1722

1823
@pytest.fixture
@@ -170,6 +175,26 @@ async def test_call_get_with_params(mock_response):
170175
assert kwargs["params"] == params
171176

172177

178+
@pytest.mark.asyncio
179+
async def test_call_post_adds_workspace_permalink_headers_at_request_time(mock_response):
180+
client = _Client()
181+
client.set_response("post", mock_response())
182+
183+
with workspace_permalink_context("team-paul", "organization"):
184+
await call_post(
185+
_client(client),
186+
"http://test.com",
187+
headers={"X-Existing": "value"},
188+
)
189+
190+
assert len(client.calls) == 1
191+
_, _args, kwargs = client.calls[0]
192+
request_headers = kwargs["headers"]
193+
assert request_headers["X-Existing"] == "value"
194+
assert request_headers[WORKSPACE_SLUG_HEADER] == "team-paul"
195+
assert request_headers[WORKSPACE_TYPE_HEADER] == "organization"
196+
197+
173198
@pytest.mark.asyncio
174199
async def test_get_error_message():
175200
"""Test the get_error_message function."""

tests/test_permalink_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ def test_workspace_qualified_permalink_candidates_include_legacy_without_rewrapp
2020
]
2121

2222

23+
def test_project_prefixed_candidate_includes_short_legacy_when_project_prefix_disabled():
24+
candidates = build_permalink_resolution_candidates(
25+
"main/notes/example",
26+
"main",
27+
include_project=False,
28+
)
29+
30+
assert candidates == [
31+
"main/notes/example",
32+
"notes/example",
33+
]
34+
35+
2336
def test_short_permalink_candidates_include_workspace_and_project_forms():
2437
candidates = build_permalink_resolution_candidates(
2538
"notes/example",

0 commit comments

Comments
 (0)