feat: Complete Phase 2 of API v2 migration - Update MCP tools to use … · basicmachines-co/basic-memory@1a74d85 · GitHub
Skip to content

Commit 1a74d85

Browse files
jope-bmclaudephernandez
authored
feat: Complete Phase 2 of API v2 migration - Update MCP tools to use v2 endpoints (#447)
Signed-off-by: Joe P <joe@basicmemory.com> Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Co-authored-by: phernandez <paul@basicmachines.co>
1 parent d71c6e8 commit 1a74d85

30 files changed

Lines changed: 596 additions & 270 deletions

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

Lines changed: 1 addition & 1 deletion

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

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,91 @@
2525
ProjectItem,
2626
ProjectStatusResponse,
2727
)
28-
from basic_memory.utils import normalize_project_path
28+
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
29+
from basic_memory.utils import normalize_project_path, generate_permalink
2930

3031
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
3132

3233

34+
@router.post("/resolve", response_model=ProjectResolveResponse)
35+
async def resolve_project_identifier(
36+
data: ProjectResolveRequest,
37+
project_repository: ProjectRepositoryDep,
38+
) -> ProjectResolveResponse:
39+
"""Resolve a project identifier (name or permalink) to a project ID.
40+
41+
This endpoint provides efficient lookup of projects by name without
42+
needing to fetch the entire project list. Supports case-insensitive
43+
matching on both name and permalink.
44+
45+
Args:
46+
data: Request containing the identifier to resolve
47+
48+
Returns:
49+
Project information including the numeric ID
50+
51+
Raises:
52+
HTTPException: 404 if project not found
53+
54+
Example:
55+
POST /v2/projects/resolve
56+
{"identifier": "my-project"}
57+
58+
Returns:
59+
{
60+
"project_id": 1,
61+
"name": "my-project",
62+
"permalink": "my-project",
63+
"path": "/path/to/project",
64+
"is_active": true,
65+
"is_default": false,
66+
"resolution_method": "name"
67+
}
68+
"""
69+
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
70+
71+
# Generate permalink for comparison
72+
identifier_permalink = generate_permalink(data.identifier)
73+
74+
# Try to find project by ID first (if identifier is numeric)
75+
resolution_method = "name"
76+
project = None
77+
78+
if data.identifier.isdigit():
79+
project_id = int(data.identifier)
80+
project = await project_repository.get_by_id(project_id)
81+
if project:
82+
resolution_method = "id"
83+
84+
# If not found by ID, try by permalink first (exact match)
85+
if not project:
86+
project = await project_repository.get_by_permalink(identifier_permalink)
87+
if project:
88+
resolution_method = "permalink"
89+
90+
# If not found by permalink, try case-insensitive name search
91+
# Uses efficient database query instead of fetching all projects
92+
if not project:
93+
project = await project_repository.get_by_name_case_insensitive(data.identifier)
94+
if project:
95+
resolution_method = "name"
96+
97+
if not project:
98+
raise HTTPException(
99+
status_code=404, detail=f"Project not found: '{data.identifier}'"
100+
)
101+
102+
return ProjectResolveResponse(
103+
project_id=project.id,
104+
name=project.name,
105+
permalink=generate_permalink(project.name),
106+
path=normalize_project_path(project.path),
107+
is_active=project.is_active if hasattr(project, "is_active") else True,
108+
is_default=project.is_default or False,
109+
resolution_method=resolution_method,
110+
)
111+
112+
33113
@router.get("/{project_id}", response_model=ProjectItem)
34114
async def get_project_by_id(
35115
project_id: ProjectIdPathDep,

src/basic_memory/cli/commands/project.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,9 @@
1616

1717
from rich.panel import Panel
1818
from basic_memory.mcp.async_client import get_client
19-
from basic_memory.mcp.tools.utils import call_get
20-
from basic_memory.schemas.project_info import ProjectList
21-
from basic_memory.mcp.tools.utils import call_post
22-
from basic_memory.schemas.project_info import ProjectStatusResponse
23-
from basic_memory.mcp.tools.utils import call_delete
24-
from basic_memory.mcp.tools.utils import call_put
19+
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
20+
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
2521
from basic_memory.utils import generate_permalink, normalize_project_path
26-
from basic_memory.mcp.tools.utils import call_patch
2722

2823
# Import rclone commands for project sync
2924
from basic_memory.cli.commands.cloud.rclone_commands import (
@@ -254,9 +249,17 @@ def remove_project(
254249

255250
async def _remove_project():
256251
async with get_client() as client:
252+
# Convert name to permalink for efficient resolution
257253
project_permalink = generate_permalink(name)
254+
255+
# Use v2 project resolver to find project ID by permalink
256+
resolve_data = {"identifier": project_permalink}
257+
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
258+
target_project = response.json()
259+
260+
# Use v2 API with project ID
258261
response = await call_delete(
259-
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
262+
client, f"/v2/projects/{target_project['project_id']}?delete_notes={delete_notes}"
260263
)
261264
return ProjectStatusResponse.model_validate(response.json())
262265

@@ -329,8 +332,16 @@ def set_default_project(
329332

330333
async def _set_default():
331334
async with get_client() as client:
335+
# Convert name to permalink for efficient resolution
332336
project_permalink = generate_permalink(name)
333-
response = await call_put(client, f"/projects/{project_permalink}/default")
337+
338+
# Use v2 project resolver to find project ID by permalink
339+
resolve_data = {"identifier": project_permalink}
340+
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
341+
target_project = response.json()
342+
343+
# Use v2 API with project ID
344+
response = await call_put(client, f"/v2/projects/{target_project['project_id']}/default")
334345
return ProjectStatusResponse.model_validate(response.json())
335346

336347
try:

src/basic_memory/deps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ async def get_entity_service(
398398
entity_parser: EntityParserDep,
399399
file_service: FileServiceDep,
400400
link_resolver: "LinkResolverDep",
401+
search_service: "SearchServiceDep",
401402
app_config: AppConfigDep,
402403
) -> EntityService:
403404
"""Create EntityService with repository."""
@@ -408,6 +409,7 @@ async def get_entity_service(
408409
entity_parser=entity_parser,
409410
file_service=file_service,
410411
link_resolver=link_resolver,
412+
search_service=search_service,
411413
app_config=app_config,
412414
)
413415

@@ -422,6 +424,7 @@ async def get_entity_service_v2(
422424
entity_parser: EntityParserV2Dep,
423425
file_service: FileServiceV2Dep,
424426
link_resolver: "LinkResolverV2Dep",
427+
search_service: "SearchServiceV2Dep",
425428
app_config: AppConfigDep,
426429
) -> EntityService:
427430
"""Create EntityService for v2 API."""
@@ -432,6 +435,7 @@ async def get_entity_service_v2(
432435
entity_parser=entity_parser,
433436
file_service=file_service,
434437
link_resolver=link_resolver,
438+
search_service=search_service,
435439
app_config=app_config,
436440
)
437441

src/basic_memory/mcp/tools/build_context.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,9 @@ async def build_context(
104104
# Get the active project using the new stateless approach
105105
active_project = await get_active_project(client, project, context)
106106

107-
project_url = active_project.project_url
108-
109107
response = await call_get(
110108
client,
111-
f"{project_url}/memory/{memory_url_path(url)}",
109+
f"/v2/projects/{active_project.id}/memory/{memory_url_path(url)}",
112110
params={
113111
"depth": depth,
114112
"timeframe": timeframe,

src/basic_memory/mcp/tools/canvas.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from basic_memory.mcp.async_client import get_client
1313
from basic_memory.mcp.project_context import get_active_project
1414
from basic_memory.mcp.server import mcp
15-
from basic_memory.mcp.tools.utils import call_put
15+
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
1616

1717

1818
@mcp.tool(
@@ -96,7 +96,6 @@ async def canvas(
9696
"""
9797
async with get_client() as client:
9898
active_project = await get_active_project(client, project, context)
99-
project_url = active_project.project_url
10099

101100
# Ensure path has .canvas extension
102101
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
@@ -108,23 +107,40 @@ async def canvas(
108107
# Convert to JSON
109108
canvas_json = json.dumps(canvas_data, indent=2)
110109

111-
# Write the file using the resource API
110+
# Try to create the canvas file first (optimistic create)
112111
logger.info(f"Creating canvas file: {file_path} in project {project}")
113-
# Send canvas_json as content string, not as json parameter
114-
# The resource endpoint expects Body() string content, not JSON-encoded data
115-
response = await call_put(
116-
client,
117-
f"{project_url}/resource/{file_path}",
118-
content=canvas_json,
119-
headers={"Content-Type": "text/plain"},
120-
)
112+
try:
113+
response = await call_post(
114+
client,
115+
f"/v2/projects/{active_project.id}/resource",
116+
json={"file_path": file_path, "content": canvas_json},
117+
)
118+
action = "Created"
119+
except Exception as e:
120+
# If creation failed due to conflict (already exists), try to update
121+
if "409" in str(e) or "conflict" in str(e).lower() or "already exists" in str(e).lower():
122+
logger.info(f"Canvas file exists, updating instead: {file_path}")
123+
try:
124+
entity_id = await resolve_entity_id(client, active_project.id, file_path)
125+
# For update, send content in JSON body
126+
response = await call_put(
127+
client,
128+
f"/v2/projects/{active_project.id}/resource/{entity_id}",
129+
json={"content": canvas_json},
130+
)
131+
action = "Updated"
132+
except Exception as update_error:
133+
# Re-raise the original error if update also fails
134+
raise e from update_error
135+
else:
136+
# Re-raise if it's not a conflict error
137+
raise
121138

122139
# Parse response
123140
result = response.json()
124141
logger.debug(result)
125142

126143
# Build summary
127-
action = "Created" if response.status_code == 201 else "Updated"
128144
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
129145

130146
return "\n".join(summary)

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
from loguru import logger
55
from fastmcp import Context
6+
from mcp.server.fastmcp.exceptions import ToolError
67

78
from basic_memory.mcp.project_context import get_active_project
8-
from basic_memory.mcp.tools.utils import call_delete
9+
from basic_memory.mcp.tools.utils import call_delete, resolve_entity_id
910
from basic_memory.mcp.server import mcp
1011
from basic_memory.mcp.async_client import get_client
1112
from basic_memory.schemas import DeleteEntitiesResponse
@@ -204,10 +205,22 @@ async def delete_note(
204205
"""
205206
async with get_client() as client:
206207
active_project = await get_active_project(client, project, context)
207-
project_url = active_project.project_url
208208

209209
try:
210-
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
210+
# Resolve identifier to entity ID
211+
entity_id = await resolve_entity_id(client, active_project.id, identifier)
212+
except ToolError as e:
213+
# If entity not found, return False (note doesn't exist)
214+
if "Entity not found" in str(e) or "not found" in str(e).lower():
215+
logger.warning(f"Note not found for deletion: {identifier}")
216+
return False
217+
# For other resolution errors, return formatted error message
218+
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
219+
return _format_delete_error_response(active_project.name, str(e), identifier)
220+
221+
try:
222+
# Call the DELETE endpoint
223+
response = await call_delete(client, f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}")
211224
result = DeleteEntitiesResponse.model_validate(response.json())
212225

213226
if result.deleted:

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from basic_memory.mcp.async_client import get_client
99
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
1010
from basic_memory.mcp.server import mcp
11-
from basic_memory.mcp.tools.utils import call_patch
11+
from basic_memory.mcp.tools.utils import call_patch, resolve_entity_id
1212
from basic_memory.schemas import EntityResponse
1313

1414

@@ -216,7 +216,6 @@ async def edit_note(
216216
"""
217217
async with get_client() as client:
218218
active_project = await get_active_project(client, project, context)
219-
project_url = active_project.project_url
220219

221220
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
222221

@@ -235,6 +234,9 @@ async def edit_note(
235234

236235
# Use the PATCH endpoint to edit the entity
237236
try:
237+
# Resolve identifier to entity ID
238+
entity_id = await resolve_entity_id(client, active_project.id, identifier)
239+
238240
# Prepare the edit request data
239241
edit_data = {
240242
"operation": operation,
@@ -250,7 +252,7 @@ async def edit_note(
250252
edit_data["expected_replacements"] = str(expected_replacements)
251253

252254
# Call the PATCH endpoint
253-
url = f"{project_url}/knowledge/entities/{identifier}"
255+
url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
254256
response = await call_patch(client, url, json=edit_data)
255257
result = EntityResponse.model_validate(response.json())
256258

src/basic_memory/mcp/tools/list_directory.py

Lines changed: 1 addition & 2 deletions

0 commit comments

Comments
 (0)