Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] d6bc8fa007 feat(mcp): add workspace parameter to delete_project for cross-workspace targeting
Adds `workspace` parameter to `delete_project` MCP tool, providing parity
with `create_memory_project`. Users can now delete projects in non-default
cloud workspaces by passing the workspace slug, name, or tenant_id.

- Rename `_resolve_create_project_workspace` → `_resolve_workspace_routing`
  (generic helper shared by both create and delete)
- Add `workspace: str | None = None` parameter to `delete_project`
- Route `get_client()` through the resolved workspace on delete
- Add 3 unit tests mirroring the create_memory_project workspace tests

Fixes #800

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-05-08 02:17:12 +00:00
2 changed files with 211 additions and 6 deletions
@@ -369,11 +369,11 @@ def _format_constrained_text(constrained_project: str) -> str:
return result
async def _resolve_create_project_workspace(
async def _resolve_workspace_routing(
workspace: str | None,
context: Context | None,
) -> str | None:
"""Resolve the create-project workspace selector to the routing tenant id."""
"""Resolve a workspace selector (slug, name, or tenant id) to the routing tenant id."""
if workspace is None:
return None
@@ -389,7 +389,7 @@ async def _resolve_create_project_workspace(
# a friendly selector such as a slug, name, or tenant id.
# Why: MCP callers should not need to paste UUIDs, but the transport still
# uses X-Workspace-ID with the tenant id as its routing authority.
# Outcome: resolve once at create time and pass only the tenant id downstream.
# Outcome: resolve once and pass only the tenant id downstream.
resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
return resolved_workspace.tenant_id
@@ -432,7 +432,7 @@ async def create_memory_project(
create_memory_project("work-notes", "/home/user/work", set_default=True)
create_memory_project("team-notes", "/team/notes", workspace="team-paul")
"""
workspace_id = await _resolve_create_project_workspace(workspace, context)
workspace_id = await _resolve_workspace_routing(workspace, context)
# workspace targets a non-default cloud workspace at create time.
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
@@ -536,7 +536,11 @@ async def create_memory_project(
@mcp.tool(
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_project(project_name: str, context: Context | None = None) -> str:
async def delete_project(
project_name: str,
workspace: str | None = None,
context: Context | None = None,
) -> str:
"""Delete a Basic Memory project.
Removes a project from the configuration and database. This does NOT delete
@@ -545,18 +549,29 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
Args:
project_name: Name of the project to delete
workspace: Optional cloud workspace selector. Slug is preferred for AI callers,
but tenant_id and unique name are also accepted. When omitted, the
connection's default workspace is used. Discover values via `list_workspaces`.
Only meaningful in cloud mode; ignored for local projects.
Returns:
Confirmation message about project deletion
Example:
delete_project("old-project")
delete_project("team-notes", workspace="big-team")
Warning:
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
async with get_client() as client:
workspace_id = await _resolve_workspace_routing(workspace, context)
# workspace targets a non-default cloud workspace.
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
# Why: the project may live in a workspace other than the connection's default.
# Outcome: cloud factory routes the list/delete requests to the correct workspace.
async with get_client(workspace=workspace_id) as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
+190
View File
@@ -747,3 +747,193 @@ async def test_list_memory_projects_aggregates_without_config_workspace(app, tes
mock_index.assert_awaited_once()
assert "- cloud-proj (cloud) [00000000-0000-0000-0000-000000000001]" in result
# --- delete_project workspace parameter tests ---
@pytest.mark.asyncio
async def test_delete_project_resolves_workspace_slug(app):
"""A friendly workspace slug resolves to the tenant id used for cloud routing on delete."""
from contextlib import asynccontextmanager
import httpx
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
captured: dict[str, str | None] = {}
target = _make_project("ws-project", "/ws-project")
@asynccontextmanager
async def fake_get_client(*, workspace=None, project_name=None):
captured["workspace"] = workspace
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
fake_status = ProjectStatusResponse(
message="Project deleted",
status="success",
default=False,
old_project=target,
)
with (
patch(
"basic_memory.mcp.tools.project_management.get_client",
new=fake_get_client,
),
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=True,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
return_value=_make_workspace(
"tenant-abc-123",
"Big Team",
workspace_type="organization",
slug="big-team",
),
) as mock_resolve_workspace,
patch.object(
ProjectClient,
"list_projects",
new_callable=AsyncMock,
return_value=_make_list([target], default=None),
),
patch.object(
ProjectClient,
"delete_project",
new_callable=AsyncMock,
return_value=fake_status,
),
patch(
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
new_callable=AsyncMock,
),
):
await delete_project("ws-project", workspace="big-team")
mock_resolve_workspace.assert_awaited_once_with(workspace="big-team", context=None)
assert captured["workspace"] == "tenant-abc-123"
@pytest.mark.asyncio
async def test_delete_project_workspace_is_local_noop(app):
"""Local delete passes workspace through unchanged without cloud workspace discovery."""
from contextlib import asynccontextmanager
import httpx
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
captured: dict[str, str | None] = {}
target = _make_project("local-ws-project", "/local-ws-project")
@asynccontextmanager
async def fake_get_client(*, workspace=None, project_name=None):
captured["workspace"] = workspace
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
fake_status = ProjectStatusResponse(
message="Project deleted",
status="success",
default=False,
old_project=target,
)
with (
patch(
"basic_memory.mcp.tools.project_management.get_client",
new=fake_get_client,
),
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=False,
),
patch(
"basic_memory.mcp.tools.project_management.has_cloud_credentials",
return_value=False,
),
patch(
"basic_memory.mcp.tools.project_management.resolve_workspace_parameter",
new_callable=AsyncMock,
) as mock_resolve_workspace,
patch.object(
ProjectClient,
"list_projects",
new_callable=AsyncMock,
return_value=_make_list([target], default=None),
),
patch.object(
ProjectClient,
"delete_project",
new_callable=AsyncMock,
return_value=fake_status,
),
patch(
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
new_callable=AsyncMock,
),
):
await delete_project("local-ws-project", workspace="big-team")
mock_resolve_workspace.assert_not_awaited()
assert captured["workspace"] == "big-team"
@pytest.mark.asyncio
async def test_delete_project_default_workspace_is_none(app):
"""When workspace is omitted, get_client receives workspace=None (default workspace)."""
from contextlib import asynccontextmanager
import httpx
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectStatusResponse
captured: dict[str, str | None] = {"workspace": "sentinel"}
target = _make_project("default-ws-project", "/default-ws-project")
@asynccontextmanager
async def fake_get_client(*, workspace=None, project_name=None):
captured["workspace"] = workspace
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
fake_status = ProjectStatusResponse(
message="Project deleted",
status="success",
default=False,
old_project=target,
)
with (
patch(
"basic_memory.mcp.tools.project_management.get_client",
new=fake_get_client,
),
patch.object(
ProjectClient,
"list_projects",
new_callable=AsyncMock,
return_value=_make_list([target], default=None),
),
patch.object(
ProjectClient,
"delete_project",
new_callable=AsyncMock,
return_value=fake_status,
),
patch(
"basic_memory.mcp.project_context.invalidate_workspace_project_index",
new_callable=AsyncMock,
),
):
await delete_project("default-ws-project")
assert captured["workspace"] is None