Skip to content

Commit a4699e0

Browse files
committed
fix(mcp): add workspace routing to delete_project
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e871298 commit a4699e0

3 files changed

Lines changed: 217 additions & 7 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,11 @@ def _format_constrained_text(constrained_project: str) -> str:
369369
return result
370370

371371

372-
async def _resolve_create_project_workspace(
372+
async def _resolve_workspace_routing(
373373
workspace: str | None,
374374
context: Context | None,
375375
) -> str | None:
376-
"""Resolve the create-project workspace selector to the routing tenant id."""
376+
"""Resolve an optional workspace selector to the routing tenant id."""
377377
if workspace is None:
378378
return None
379379

@@ -389,7 +389,8 @@ async def _resolve_create_project_workspace(
389389
# a friendly selector such as a slug, name, or tenant id.
390390
# Why: MCP callers should not need to paste UUIDs, but the transport still
391391
# uses X-Workspace-ID with the tenant id as its routing authority.
392-
# Outcome: resolve once at create time and pass only the tenant id downstream.
392+
# Outcome: resolve once before the project-management request and pass only
393+
# the tenant id downstream.
393394
resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
394395
return resolved_workspace.tenant_id
395396

@@ -432,7 +433,7 @@ async def create_memory_project(
432433
create_memory_project("work-notes", "/home/user/work", set_default=True)
433434
create_memory_project("team-notes", "/team/notes", workspace="team-paul")
434435
"""
435-
workspace_id = await _resolve_create_project_workspace(workspace, context)
436+
workspace_id = await _resolve_workspace_routing(workspace, context)
436437

437438
# workspace targets a non-default cloud workspace at create time.
438439
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
@@ -536,7 +537,11 @@ async def create_memory_project(
536537
@mcp.tool(
537538
annotations={"destructiveHint": True, "openWorldHint": False},
538539
)
539-
async def delete_project(project_name: str, context: Context | None = None) -> str:
540+
async def delete_project(
541+
project_name: str,
542+
workspace: str | None = None,
543+
context: Context | None = None,
544+
) -> str:
540545
"""Delete a Basic Memory project.
541546
542547
Removes a project from the configuration and database. This does NOT delete
@@ -545,18 +550,25 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
545550
546551
Args:
547552
project_name: Name of the project to delete
553+
workspace: Optional cloud workspace selector to delete the project from.
554+
Slug is preferred for AI callers, but tenant_id and unique name are
555+
also accepted. When omitted, the connection's default workspace is
556+
used. Only meaningful in cloud mode; ignored for local projects.
548557
549558
Returns:
550559
Confirmation message about project deletion
551560
552561
Example:
553562
delete_project("old-project")
563+
delete_project("team-project", workspace="team-paul")
554564
555565
Warning:
556566
This action cannot be undone. The project will need to be re-added
557567
to access its content through Basic Memory again.
558568
"""
559-
async with get_client() as client:
569+
workspace_id = await _resolve_workspace_routing(workspace, context)
570+
571+
async with get_client(workspace=workspace_id) as client:
560572
# Check if server is constrained to a specific project
561573
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
562574
if constrained_project:

tests/mcp/test_tool_contracts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"output_format",
3232
],
3333
"delete_note": ["identifier", "is_directory", "project", "project_id", "output_format"],
34-
"delete_project": ["project_name"],
34+
"delete_project": ["project_name", "workspace"],
3535
"edit_note": [
3636
"identifier",
3737
"operation",

tests/mcp/test_tool_project_management.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,204 @@ async def fake_get_client(*, workspace=None, project_name=None):
328328
assert captured["workspace"] is None
329329

330330

331+
@pytest.mark.asyncio
332+
async def test_delete_project_resolves_workspace_slug(app):
333+
"""A friendly workspace slug resolves to the tenant id used for delete routing."""
334+
from contextlib import asynccontextmanager
335+
import httpx
336+
337+
from basic_memory.mcp.clients import ProjectClient
338+
from basic_memory.schemas.project_info import ProjectStatusResponse
339+
340+
target_project = _make_project(
341+
"WS Project",
342+
"/ws-project",
343+
external_id="project-uuid",
344+
)
345+
captured: dict[str, str | None] = {}
346+
347+
@asynccontextmanager
348+
async def fake_get_client(*, workspace=None, project_name=None):
349+
captured["workspace"] = workspace
350+
async with httpx.AsyncClient(base_url="http://testserver") as client:
351+
yield client
352+
353+
fake_status = ProjectStatusResponse(
354+
message="Project deleted",
355+
status="success",
356+
default=False,
357+
old_project=target_project,
358+
)
359+
360+
with (
361+
patch(
362+
"basic_memory.mcp.tools.project_management.get_client",
363+
new=fake_get_client,
364+
),
365+
patch(
366+
"basic_memory.mcp.tools.project_management.is_factory_mode",
367+
return_value=True,
368+
),
369+
patch(
370+
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
371+
new_callable=AsyncMock,
372+
return_value=_make_workspace(
373+
"tenant-abc-123",
374+
"Team Paul",
375+
workspace_type="organization",
376+
slug="team-paul",
377+
),
378+
) as mock_resolve_workspace,
379+
patch.object(
380+
ProjectClient,
381+
"list_projects",
382+
new_callable=AsyncMock,
383+
return_value=_make_list([target_project], default=None),
384+
),
385+
patch.object(
386+
ProjectClient,
387+
"delete_project",
388+
new_callable=AsyncMock,
389+
return_value=fake_status,
390+
) as mock_delete_project,
391+
patch(
392+
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
393+
new_callable=AsyncMock,
394+
),
395+
):
396+
result = await delete_project("WS Project", workspace="team-paul")
397+
398+
mock_resolve_workspace.assert_awaited_once_with(workspace="team-paul", context=None)
399+
assert captured["workspace"] == "tenant-abc-123"
400+
mock_delete_project.assert_awaited_once_with("project-uuid")
401+
assert result.startswith("✓")
402+
403+
404+
@pytest.mark.asyncio
405+
async def test_delete_project_workspace_is_local_noop(app):
406+
"""Local delete accepts workspace without requiring cloud workspace discovery."""
407+
from contextlib import asynccontextmanager
408+
import httpx
409+
410+
from basic_memory.mcp.clients import ProjectClient
411+
from basic_memory.schemas.project_info import ProjectStatusResponse
412+
413+
target_project = _make_project(
414+
"Local WS Project",
415+
"/local-ws-project",
416+
external_id="local-project-uuid",
417+
)
418+
captured: dict[str, str | None] = {}
419+
420+
@asynccontextmanager
421+
async def fake_get_client(*, workspace=None, project_name=None):
422+
captured["workspace"] = workspace
423+
async with httpx.AsyncClient(base_url="http://testserver") as client:
424+
yield client
425+
426+
fake_status = ProjectStatusResponse(
427+
message="Project deleted",
428+
status="success",
429+
default=False,
430+
old_project=target_project,
431+
)
432+
433+
with (
434+
patch(
435+
"basic_memory.mcp.tools.project_management.get_client",
436+
new=fake_get_client,
437+
),
438+
patch(
439+
"basic_memory.mcp.tools.project_management.is_factory_mode",
440+
return_value=False,
441+
),
442+
patch(
443+
"basic_memory.mcp.tools.project_management.has_cloud_credentials",
444+
return_value=False,
445+
),
446+
patch(
447+
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
448+
new_callable=AsyncMock,
449+
) as mock_resolve_workspace,
450+
patch.object(
451+
ProjectClient,
452+
"list_projects",
453+
new_callable=AsyncMock,
454+
return_value=_make_list([target_project], default=None),
455+
),
456+
patch.object(
457+
ProjectClient,
458+
"delete_project",
459+
new_callable=AsyncMock,
460+
return_value=fake_status,
461+
),
462+
patch(
463+
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
464+
new_callable=AsyncMock,
465+
),
466+
):
467+
await delete_project("Local WS Project", workspace="team-paul")
468+
469+
mock_resolve_workspace.assert_not_awaited()
470+
assert captured["workspace"] == "team-paul"
471+
472+
473+
@pytest.mark.asyncio
474+
async def test_delete_project_default_workspace_is_none(app):
475+
"""When workspace is omitted, delete_project routes through the default workspace."""
476+
from contextlib import asynccontextmanager
477+
import httpx
478+
479+
from basic_memory.mcp.clients import ProjectClient
480+
from basic_memory.schemas.project_info import ProjectStatusResponse
481+
482+
target_project = _make_project(
483+
"Default WS Project",
484+
"/default-ws-project",
485+
external_id="default-project-uuid",
486+
)
487+
captured: dict[str, str | None] = {"workspace": "sentinel"}
488+
489+
@asynccontextmanager
490+
async def fake_get_client(*, workspace=None, project_name=None):
491+
captured["workspace"] = workspace
492+
async with httpx.AsyncClient(base_url="http://testserver") as client:
493+
yield client
494+
495+
fake_status = ProjectStatusResponse(
496+
message="Project deleted",
497+
status="success",
498+
default=False,
499+
old_project=target_project,
500+
)
501+
502+
with (
503+
patch(
504+
"basic_memory.mcp.tools.project_management.get_client",
505+
new=fake_get_client,
506+
),
507+
patch.object(
508+
ProjectClient,
509+
"list_projects",
510+
new_callable=AsyncMock,
511+
return_value=_make_list([target_project], default=None),
512+
),
513+
patch.object(
514+
ProjectClient,
515+
"delete_project",
516+
new_callable=AsyncMock,
517+
return_value=fake_status,
518+
),
519+
patch(
520+
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
521+
new_callable=AsyncMock,
522+
),
523+
):
524+
await delete_project("Default WS Project")
525+
526+
assert captured["workspace"] is None
527+
528+
331529
# --- Cloud merge tests ---
332530

333531

0 commit comments

Comments
 (0)