mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6bc8fa007 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user