Skip to content

Commit b08659e

Browse files
authored
fix(api): accept qualified project resolver hints (#795)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 09a4b09 commit b08659e

2 files changed

Lines changed: 87 additions & 23 deletions

File tree

src/basic_memory/api/v2/routers/project_router.py

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313
import os
14-
from typing import Optional
14+
from typing import Literal, Optional
1515

1616
from fastapi import APIRouter, HTTPException, Body, Query, Path
1717
from loguru import logger
@@ -25,6 +25,8 @@
2525
ProjectExternalIdPathDep,
2626
)
2727
from basic_memory.schemas import SyncReportResponse
28+
from basic_memory.models import Project
29+
from basic_memory.repository.project_repository import ProjectRepository
2830
from basic_memory.schemas.project_info import (
2931
ProjectItem,
3032
ProjectList,
@@ -36,6 +38,71 @@
3638
from basic_memory.utils import normalize_project_path, generate_permalink
3739

3840
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
41+
ProjectResolveMethod = Literal["external_id", "name", "permalink"]
42+
43+
44+
def _split_qualified_project_identifier(identifier: str) -> tuple[str | None, str]:
45+
"""Split ``<workspace>/<project>`` identifiers while preserving plain project names."""
46+
cleaned = identifier.strip()
47+
if "/" not in cleaned:
48+
return None, cleaned
49+
50+
workspace_identifier, project_identifier = cleaned.split("/", 1)
51+
if not workspace_identifier or not project_identifier:
52+
return None, cleaned
53+
return workspace_identifier, project_identifier
54+
55+
56+
async def _resolve_project_identifier_candidate(
57+
project_repository: ProjectRepository,
58+
identifier: str,
59+
) -> tuple[Project | None, ProjectResolveMethod]:
60+
"""Resolve one project identifier candidate and report the matching method."""
61+
identifier_permalink = generate_permalink(identifier)
62+
63+
project = await project_repository.get_by_external_id(identifier)
64+
if project:
65+
return project, "external_id"
66+
67+
project = await project_repository.get_by_permalink(identifier_permalink)
68+
if project:
69+
return project, "permalink"
70+
71+
project = await project_repository.get_by_name_case_insensitive(identifier)
72+
if project:
73+
return project, "name" # pragma: no cover
74+
75+
return None, "name"
76+
77+
78+
async def _resolve_project_identifier(
79+
project_repository: ProjectRepository,
80+
identifier: str,
81+
) -> tuple[Project | None, ProjectResolveMethod]:
82+
"""Resolve exact identifiers first, then accepted workspace-qualified forms."""
83+
project, resolution_method = await _resolve_project_identifier_candidate(
84+
project_repository,
85+
identifier,
86+
)
87+
if project:
88+
return project, resolution_method
89+
90+
workspace_identifier, project_identifier = _split_qualified_project_identifier(identifier)
91+
if workspace_identifier is None:
92+
return None, resolution_method
93+
94+
# Trigger: an MCP disambiguation error suggested ``workspace/project``.
95+
# Why: request routing already selected the workspace/tenant; this endpoint
96+
# only needs the project segment to validate the active project.
97+
# Outcome: models can follow the hint verbatim instead of looping on a 404.
98+
project, resolution_method = await _resolve_project_identifier_candidate(
99+
project_repository,
100+
project_identifier,
101+
)
102+
if project:
103+
return project, resolution_method
104+
105+
return None, resolution_method
39106

40107

41108
@router.get("/", response_model=ProjectList)
@@ -247,28 +314,10 @@ async def resolve_project_identifier(
247314
"""
248315
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
249316

250-
# Generate permalink for comparison
251-
identifier_permalink = generate_permalink(data.identifier)
252-
253-
resolution_method = "name"
254-
project = None
255-
256-
# Try external_id first (UUID format)
257-
project = await project_repository.get_by_external_id(data.identifier)
258-
if project:
259-
resolution_method = "external_id"
260-
261-
# If not found by external_id, try by permalink (exact match)
262-
if not project:
263-
project = await project_repository.get_by_permalink(identifier_permalink)
264-
if project:
265-
resolution_method = "permalink"
266-
267-
# If not found by permalink, try case-insensitive name search
268-
if not project:
269-
project = await project_repository.get_by_name_case_insensitive(data.identifier)
270-
if project:
271-
resolution_method = "name" # pragma: no cover
317+
project, resolution_method = await _resolve_project_identifier(
318+
project_repository,
319+
data.identifier,
320+
)
272321

273322
if not project:
274323
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")

tests/api/v2/test_project_router.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,21 @@ async def test_resolve_project_by_permalink(
321321
assert resolved.resolution_method in ["name", "permalink"]
322322

323323

324+
@pytest.mark.asyncio
325+
async def test_resolve_project_by_workspace_qualified_permalink(
326+
client: AsyncClient, test_project: Project, v2_projects_url
327+
):
328+
"""Resolve the workspace/project form shown by MCP disambiguation errors."""
329+
resolve_data = {"identifier": f"personal/{test_project.name}"}
330+
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
331+
332+
assert response.status_code == 200
333+
resolved = ProjectResolveResponse.model_validate(response.json())
334+
assert resolved.external_id == test_project.external_id
335+
assert resolved.name == test_project.name
336+
assert resolved.resolution_method == "permalink"
337+
338+
324339
@pytest.mark.asyncio
325340
async def test_resolve_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url):
326341
"""Test resolving a project by external_id string returns correct project external_id."""

0 commit comments

Comments
 (0)