Skip to content

Commit f312341

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

3 files changed

Lines changed: 293 additions & 40 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 48 additions & 30 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

@@ -418,8 +419,8 @@ async def create_memory_project(
418419
workspace: Optional cloud workspace selector to create the project in. Slug is
419420
preferred for AI callers, but tenant_id and unique name are also accepted.
420421
When omitted, the connection's default workspace is used. Discover values
421-
via `list_workspaces`. Only meaningful in cloud mode; ignored for local
422-
projects.
422+
via `list_workspaces`. In local mode the selector is passed through
423+
without slug resolution.
423424
output_format: "text" returns the existing human-readable result text.
424425
"json" returns structured project creation metadata.
425426
context: Optional FastMCP context for progress/status logging.
@@ -432,31 +433,34 @@ 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+
# Trigger: MCP server is constrained to a single project.
437+
# Why: constrained sessions cannot create projects, and workspace selectors
438+
# may be invalid or unavailable in that locked context.
439+
# Outcome: return the existing disabled response before opening a routed client.
440+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
441+
if constrained_project:
442+
if output_format == "json":
443+
return {
444+
"name": project_name,
445+
"path": project_path,
446+
"is_default": False,
447+
"created": False,
448+
"already_exists": False,
449+
"error": "PROJECT_CONSTRAINED",
450+
"message": (
451+
f"Project creation disabled - MCP server is constrained to project "
452+
f"'{constrained_project}'."
453+
),
454+
}
455+
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
456+
457+
workspace_id = await _resolve_workspace_routing(workspace, context)
436458

437459
# workspace targets a non-default cloud workspace at create time.
438460
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
439461
# Why: there is no project_id yet for per-project routing — the project doesn't exist.
440462
# Outcome: cloud factory routes the create request to the resolved workspace tenant id.
441463
async with get_client(workspace=workspace_id) as client:
442-
# Check if server is constrained to a specific project
443-
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
444-
if constrained_project:
445-
if output_format == "json":
446-
return {
447-
"name": project_name,
448-
"path": project_path,
449-
"is_default": False,
450-
"created": False,
451-
"already_exists": False,
452-
"error": "PROJECT_CONSTRAINED",
453-
"message": (
454-
f"Project creation disabled - MCP server is constrained to project "
455-
f"'{constrained_project}'."
456-
),
457-
}
458-
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
459-
460464
if context: # pragma: no cover
461465
await context.info(f"Creating project: {project_name} at {project_path}")
462466

@@ -536,7 +540,11 @@ async def create_memory_project(
536540
@mcp.tool(
537541
annotations={"destructiveHint": True, "openWorldHint": False},
538542
)
539-
async def delete_project(project_name: str, context: Context | None = None) -> str:
543+
async def delete_project(
544+
project_name: str,
545+
workspace: str | None = None,
546+
context: Context | None = None,
547+
) -> str:
540548
"""Delete a Basic Memory project.
541549
542550
Removes a project from the configuration and database. This does NOT delete
@@ -545,23 +553,33 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
545553
546554
Args:
547555
project_name: Name of the project to delete
556+
workspace: Optional cloud workspace selector to delete the project from.
557+
Slug is preferred for AI callers, but tenant_id and unique name are
558+
also accepted. When omitted, the connection's default workspace is
559+
used. In local mode the selector is passed through without slug
560+
resolution, matching create_memory_project behavior.
548561
549562
Returns:
550563
Confirmation message about project deletion
551564
552565
Example:
553566
delete_project("old-project")
567+
delete_project("team-project", workspace="team-paul")
554568
555569
Warning:
556570
This action cannot be undone. The project will need to be re-added
557571
to access its content through Basic Memory again.
558572
"""
559-
async with get_client() as client:
560-
# Check if server is constrained to a specific project
561-
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
562-
if constrained_project:
563-
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
573+
# Trigger: MCP server is constrained to a single project.
574+
# Why: constrained sessions cannot delete projects, and workspace selectors
575+
# may be invalid or unavailable in that locked context.
576+
# Outcome: return the existing disabled message before opening a routed client.
577+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
578+
if constrained_project:
579+
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
564580

581+
workspace_id = await _resolve_workspace_routing(workspace, context)
582+
async with get_client(workspace=workspace_id) as client:
565583
if context: # pragma: no cover
566584
await context.info(f"Deleting project: {project_name}")
567585

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",

0 commit comments

Comments
 (0)