Skip to content

Commit a661e92

Browse files
authored
feat(mcp): create projects by workspace slug (#789)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 05adda1 commit a661e92

4 files changed

Lines changed: 367 additions & 3 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@
1111
from loguru import logger
1212

1313
from basic_memory.config import ConfigManager, has_cloud_credentials
14-
from basic_memory.mcp.async_client import get_client, is_factory_mode
14+
from basic_memory.mcp.async_client import (
15+
_explicit_routing,
16+
_force_local_mode,
17+
get_client,
18+
is_factory_mode,
19+
)
1520
from basic_memory.mcp.project_context import (
1621
WorkspaceProjectEntry,
1722
ensure_workspace_project_index,
23+
resolve_workspace_parameter,
1824
)
1925
from basic_memory.mcp.server import mcp
2026
from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList
@@ -363,6 +369,31 @@ def _format_constrained_text(constrained_project: str) -> str:
363369
return result
364370

365371

372+
async def _resolve_create_project_workspace(
373+
workspace: str | None,
374+
context: Context | None,
375+
) -> str | None:
376+
"""Resolve the create-project workspace selector to the routing tenant id."""
377+
if workspace is None:
378+
return None
379+
380+
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
381+
config = ConfigManager().config
382+
should_resolve_workspace = is_factory_mode() or (
383+
explicit_cloud_routing and has_cloud_credentials(config)
384+
)
385+
if not should_resolve_workspace:
386+
return workspace
387+
388+
# Trigger: cloud routing can use workspace discovery and the caller supplied
389+
# a friendly selector such as a slug, name, or tenant id.
390+
# Why: MCP callers should not need to paste UUIDs, but the transport still
391+
# 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.
393+
resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context)
394+
return resolved_workspace.tenant_id
395+
396+
366397
@mcp.tool(
367398
"create_memory_project",
368399
annotations={"destructiveHint": False, "openWorldHint": False},
@@ -371,6 +402,7 @@ async def create_memory_project(
371402
project_name: str,
372403
project_path: str,
373404
set_default: bool = False,
405+
workspace: str | None = None,
374406
output_format: Literal["text", "json"] = "text",
375407
context: Context | None = None,
376408
) -> str | dict:
@@ -383,6 +415,11 @@ async def create_memory_project(
383415
project_name: Name for the new project (must be unique)
384416
project_path: File system path where the project will be stored
385417
set_default: Whether to set this project as the default (optional, defaults to False)
418+
workspace: Optional cloud workspace selector to create the project in. Slug is
419+
preferred for AI callers, but tenant_id and unique name are also accepted.
420+
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.
386423
output_format: "text" returns the existing human-readable result text.
387424
"json" returns structured project creation metadata.
388425
context: Optional FastMCP context for progress/status logging.
@@ -393,8 +430,15 @@ async def create_memory_project(
393430
Example:
394431
create_memory_project("my-research", "~/Documents/research")
395432
create_memory_project("work-notes", "/home/user/work", set_default=True)
433+
create_memory_project("team-notes", "/team/notes", workspace="team-paul")
396434
"""
397-
async with get_client() as client:
435+
workspace_id = await _resolve_create_project_workspace(workspace, context)
436+
437+
# workspace targets a non-default cloud workspace at create time.
438+
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
439+
# Why: there is no project_id yet for per-project routing — the project doesn't exist.
440+
# Outcome: cloud factory routes the create request to the resolved workspace tenant id.
441+
async with get_client(workspace=workspace_id) as client:
398442
# Check if server is constrained to a specific project
399443
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
400444
if constrained_project:

test-int/mcp/test_project_management_integration.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,3 +588,122 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_
588588

589589
# Clean up parent project
590590
await client.call_tool("delete_project", {"project_name": parent_name})
591+
592+
593+
@pytest.mark.asyncio
594+
async def test_create_project_accepts_workspace_in_local_mode(
595+
mcp_server, app, test_project, tmp_path
596+
):
597+
"""Passing workspace via the MCP wire is accepted by the tool schema and
598+
does not break the local create path.
599+
600+
In local mode there is no cloud factory installed, so workspace is a no-op:
601+
the request lands on the ASGI transport which has no workspace concept. This
602+
test guards the schema so a future change can't accidentally drop the parameter.
603+
"""
604+
605+
async with Client(mcp_server) as client:
606+
create_result = await client.call_tool(
607+
"create_memory_project",
608+
{
609+
"project_name": "ws-local-test",
610+
"project_path": str(
611+
tmp_path.parent / (tmp_path.name + "-projects") / "project-ws-local-test"
612+
),
613+
"workspace": "team-paul",
614+
},
615+
)
616+
617+
assert len(create_result.content) == 1
618+
create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
619+
assert "✓" in create_text
620+
assert "ws-local-test" in create_text
621+
622+
list_result = await client.call_tool("list_memory_projects", {})
623+
assert "ws-local-test" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
624+
625+
626+
@pytest.mark.asyncio
627+
async def test_create_project_workspace_slug_forwarded_to_factory_as_tenant_id(
628+
mcp_server, app, test_project, tmp_path
629+
):
630+
"""workspace slug resolves before the tenant id flows to the cloud factory.
631+
632+
Simulates the cloud MCP server pattern (set_client_factory) and verifies the
633+
factory receives the workspace argument. This is the chicken-and-egg case:
634+
no project_id exists yet, so workspace is the only way to target a
635+
non-default workspace at create time.
636+
"""
637+
from contextlib import asynccontextmanager
638+
from unittest.mock import AsyncMock, patch
639+
640+
from httpx import ASGITransport, AsyncClient as HttpxAsyncClient
641+
642+
from basic_memory.mcp import async_client
643+
from basic_memory.mcp.tools import project_management
644+
from basic_memory.schemas.cloud import WorkspaceInfo
645+
646+
captured_workspaces: list[str | None] = []
647+
resolved_workspace = WorkspaceInfo(
648+
tenant_id="tenant-cloud-test",
649+
name="Team Paul",
650+
workspace_type="organization",
651+
slug="team-paul",
652+
role="owner",
653+
organization_id="org-team-paul",
654+
is_default=False,
655+
has_active_subscription=True,
656+
)
657+
658+
@asynccontextmanager
659+
async def fake_factory(workspace=None):
660+
captured_workspaces.append(workspace)
661+
# Yield an ASGI-backed httpx client so the create_project HTTP call
662+
# actually reaches the FastAPI app and the project is created in the DB.
663+
async with HttpxAsyncClient(
664+
transport=ASGITransport(app=app), base_url="http://test"
665+
) as inner:
666+
yield inner
667+
668+
original_factory = async_client._client_factory
669+
async_client.set_client_factory(fake_factory)
670+
try:
671+
with patch.object(
672+
project_management,
673+
"resolve_workspace_parameter",
674+
new_callable=AsyncMock,
675+
return_value=resolved_workspace,
676+
) as mock_resolve_workspace:
677+
async with Client(mcp_server) as mcp_client:
678+
create_result = await mcp_client.call_tool(
679+
"create_memory_project",
680+
{
681+
"project_name": "ws-routed-project",
682+
"project_path": str(
683+
tmp_path.parent
684+
/ (tmp_path.name + "-projects")
685+
/ "project-ws-routed-project"
686+
),
687+
"workspace": "team-paul",
688+
},
689+
)
690+
691+
create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
692+
assert "✓" in create_text
693+
assert "ws-routed-project" in create_text
694+
695+
mock_resolve_workspace.assert_awaited_once()
696+
await_args = mock_resolve_workspace.await_args
697+
assert await_args is not None
698+
assert await_args.kwargs["workspace"] == "team-paul"
699+
# The factory must have been invoked with the tenant id resolved from the slug.
700+
# create_memory_project opens one get_client() context, so the factory is
701+
# called once per tool invocation; both list_projects and create_project
702+
# share that single client.
703+
assert captured_workspaces, "Factory was never invoked"
704+
assert all(ws == "tenant-cloud-test" for ws in captured_workspaces), (
705+
"Expected workspace='tenant-cloud-test' on every factory call, "
706+
f"got {captured_workspaces}"
707+
)
708+
finally:
709+
async_client._client_factory = original_factory

tests/mcp/test_tool_contracts.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@
2323
],
2424
"canvas": ["nodes", "edges", "title", "directory", "project", "project_id"],
2525
"cloud_info": [],
26-
"create_memory_project": ["project_name", "project_path", "set_default", "output_format"],
26+
"create_memory_project": [
27+
"project_name",
28+
"project_path",
29+
"set_default",
30+
"workspace",
31+
"output_format",
32+
],
2733
"delete_note": ["identifier", "is_directory", "project", "project_id", "output_format"],
2834
"delete_project": ["project_name"],
2935
"edit_note": [

0 commit comments

Comments
 (0)