diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index f9449b4f..c2b13dfe 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -11,10 +11,16 @@ from fastmcp import Context from loguru import logger from basic_memory.config import ConfigManager, has_cloud_credentials -from basic_memory.mcp.async_client import get_client, is_factory_mode +from basic_memory.mcp.async_client import ( + _explicit_routing, + _force_local_mode, + get_client, + is_factory_mode, +) from basic_memory.mcp.project_context import ( WorkspaceProjectEntry, ensure_workspace_project_index, + resolve_workspace_parameter, ) from basic_memory.mcp.server import mcp from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList @@ -363,6 +369,31 @@ def _format_constrained_text(constrained_project: str) -> str: return result +async def _resolve_create_project_workspace( + workspace: str | None, + context: Context | None, +) -> str | None: + """Resolve the create-project workspace selector to the routing tenant id.""" + if workspace is None: + return None + + explicit_cloud_routing = _explicit_routing() and not _force_local_mode() + config = ConfigManager().config + should_resolve_workspace = is_factory_mode() or ( + explicit_cloud_routing and has_cloud_credentials(config) + ) + if not should_resolve_workspace: + return workspace + + # Trigger: cloud routing can use workspace discovery and the caller supplied + # 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. + resolved_workspace = await resolve_workspace_parameter(workspace=workspace, context=context) + return resolved_workspace.tenant_id + + @mcp.tool( "create_memory_project", annotations={"destructiveHint": False, "openWorldHint": False}, @@ -371,6 +402,7 @@ async def create_memory_project( project_name: str, project_path: str, set_default: bool = False, + workspace: str | None = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, ) -> str | dict: @@ -383,6 +415,11 @@ async def create_memory_project( project_name: Name for the new project (must be unique) project_path: File system path where the project will be stored set_default: Whether to set this project as the default (optional, defaults to False) + workspace: Optional cloud workspace selector to create the project in. 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. output_format: "text" returns the existing human-readable result text. "json" returns structured project creation metadata. context: Optional FastMCP context for progress/status logging. @@ -393,8 +430,15 @@ async def create_memory_project( Example: create_memory_project("my-research", "~/Documents/research") create_memory_project("work-notes", "/home/user/work", set_default=True) + create_memory_project("team-notes", "/team/notes", workspace="team-paul") """ - async with get_client() as client: + workspace_id = await _resolve_create_project_workspace(workspace, context) + + # workspace targets a non-default cloud workspace at create time. + # Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces). + # Why: there is no project_id yet for per-project routing — the project doesn't exist. + # Outcome: cloud factory routes the create request to the resolved workspace tenant id. + 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: diff --git a/test-int/mcp/test_project_management_integration.py b/test-int/mcp/test_project_management_integration.py index 58606732..b663ab61 100644 --- a/test-int/mcp/test_project_management_integration.py +++ b/test-int/mcp/test_project_management_integration.py @@ -588,3 +588,122 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_ # Clean up parent project await client.call_tool("delete_project", {"project_name": parent_name}) + + +@pytest.mark.asyncio +async def test_create_project_accepts_workspace_in_local_mode( + mcp_server, app, test_project, tmp_path +): + """Passing workspace via the MCP wire is accepted by the tool schema and + does not break the local create path. + + In local mode there is no cloud factory installed, so workspace is a no-op: + the request lands on the ASGI transport which has no workspace concept. This + test guards the schema so a future change can't accidentally drop the parameter. + """ + + async with Client(mcp_server) as client: + create_result = await client.call_tool( + "create_memory_project", + { + "project_name": "ws-local-test", + "project_path": str( + tmp_path.parent / (tmp_path.name + "-projects") / "project-ws-local-test" + ), + "workspace": "team-paul", + }, + ) + + assert len(create_result.content) == 1 + create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue] + assert "✓" in create_text + assert "ws-local-test" in create_text + + list_result = await client.call_tool("list_memory_projects", {}) + assert "ws-local-test" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue] + + +@pytest.mark.asyncio +async def test_create_project_workspace_slug_forwarded_to_factory_as_tenant_id( + mcp_server, app, test_project, tmp_path +): + """workspace slug resolves before the tenant id flows to the cloud factory. + + Simulates the cloud MCP server pattern (set_client_factory) and verifies the + factory receives the workspace argument. This is the chicken-and-egg case: + no project_id exists yet, so workspace is the only way to target a + non-default workspace at create time. + """ + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock, patch + + from httpx import ASGITransport, AsyncClient as HttpxAsyncClient + + from basic_memory.mcp import async_client + from basic_memory.mcp.tools import project_management + from basic_memory.schemas.cloud import WorkspaceInfo + + captured_workspaces: list[str | None] = [] + resolved_workspace = WorkspaceInfo( + tenant_id="tenant-cloud-test", + name="Team Paul", + workspace_type="organization", + slug="team-paul", + role="owner", + organization_id="org-team-paul", + is_default=False, + has_active_subscription=True, + ) + + @asynccontextmanager + async def fake_factory(workspace=None): + captured_workspaces.append(workspace) + # Yield an ASGI-backed httpx client so the create_project HTTP call + # actually reaches the FastAPI app and the project is created in the DB. + async with HttpxAsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as inner: + yield inner + + original_factory = async_client._client_factory + async_client.set_client_factory(fake_factory) + try: + with patch.object( + project_management, + "resolve_workspace_parameter", + new_callable=AsyncMock, + return_value=resolved_workspace, + ) as mock_resolve_workspace: + async with Client(mcp_server) as mcp_client: + create_result = await mcp_client.call_tool( + "create_memory_project", + { + "project_name": "ws-routed-project", + "project_path": str( + tmp_path.parent + / (tmp_path.name + "-projects") + / "project-ws-routed-project" + ), + "workspace": "team-paul", + }, + ) + + create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue] + assert "✓" in create_text + assert "ws-routed-project" in create_text + + mock_resolve_workspace.assert_awaited_once() + await_args = mock_resolve_workspace.await_args + assert await_args is not None + assert await_args.kwargs["workspace"] == "team-paul" + # The factory must have been invoked with the tenant id resolved from the slug. + # create_memory_project opens one get_client() context, so the factory is + # called once per tool invocation; both list_projects and create_project + # share that single client. + assert captured_workspaces, "Factory was never invoked" + assert all(ws == "tenant-cloud-test" for ws in captured_workspaces), ( + "Expected workspace='tenant-cloud-test' on every factory call, " + f"got {captured_workspaces}" + ) + finally: + async_client._client_factory = original_factory diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 6327a2ab..afd101cc 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -23,7 +23,13 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = { ], "canvas": ["nodes", "edges", "title", "directory", "project", "project_id"], "cloud_info": [], - "create_memory_project": ["project_name", "project_path", "set_default", "output_format"], + "create_memory_project": [ + "project_name", + "project_path", + "set_default", + "workspace", + "output_format", + ], "delete_note": ["identifier", "is_directory", "project", "project_id", "output_format"], "delete_project": ["project_name"], "edit_note": [ diff --git a/tests/mcp/test_tool_project_management.py b/tests/mcp/test_tool_project_management.py index 812539de..1ce67a49 100644 --- a/tests/mcp/test_tool_project_management.py +++ b/tests/mcp/test_tool_project_management.py @@ -133,6 +133,201 @@ async def test_create_and_delete_project_and_name_match_branch( assert delete_result.startswith("✓") +@pytest.mark.asyncio +async def test_create_memory_project_resolves_workspace_slug(app, tmp_path_factory): + """A friendly workspace slug resolves to the tenant id used for cloud routing.""" + from contextlib import asynccontextmanager + import httpx + + from basic_memory.mcp.clients import ProjectClient + from basic_memory.schemas.project_info import ProjectStatusResponse + + project_root = tmp_path_factory.mktemp("ws-project-home") + captured: dict[str, str | None] = {} + + @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 created", + status="success", + default=False, + new_project=_make_project("WS Project", str(project_root)), + ) + + 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", + "Team Paul", + workspace_type="organization", + slug="team-paul", + ), + ) as mock_resolve_workspace, + patch.object( + ProjectClient, + "list_projects", + new_callable=AsyncMock, + return_value=_make_list([], default=None), + ), + patch.object( + ProjectClient, + "create_project", + new_callable=AsyncMock, + return_value=fake_status, + ), + patch( + "basic_memory.mcp.project_context.invalidate_workspace_project_index", + new_callable=AsyncMock, + ), + ): + await create_memory_project( + project_name="WS Project", + project_path=str(project_root), + workspace="team-paul", + ) + + mock_resolve_workspace.assert_awaited_once_with(workspace="team-paul", context=None) + assert captured["workspace"] == "tenant-abc-123" + + +@pytest.mark.asyncio +async def test_create_memory_project_workspace_is_local_noop(app, tmp_path_factory): + """Local create accepts workspace without requiring cloud workspace discovery.""" + from contextlib import asynccontextmanager + import httpx + + from basic_memory.mcp.clients import ProjectClient + from basic_memory.schemas.project_info import ProjectStatusResponse + + project_root = tmp_path_factory.mktemp("local-ws-project-home") + captured: dict[str, str | None] = {} + + @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 created", + status="success", + default=False, + new_project=_make_project("Local WS Project", str(project_root)), + ) + + 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([], default=None), + ), + patch.object( + ProjectClient, + "create_project", + new_callable=AsyncMock, + return_value=fake_status, + ), + patch( + "basic_memory.mcp.project_context.invalidate_workspace_project_index", + new_callable=AsyncMock, + ), + ): + await create_memory_project( + project_name="Local WS Project", + project_path=str(project_root), + workspace="team-paul", + ) + + mock_resolve_workspace.assert_not_awaited() + assert captured["workspace"] == "team-paul" + + +@pytest.mark.asyncio +async def test_create_memory_project_default_workspace_is_none(app, tmp_path_factory): + """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 + + project_root = tmp_path_factory.mktemp("default-ws-project-home") + captured: dict[str, str | None] = {"workspace": "sentinel"} + + @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 created", + status="success", + default=False, + new_project=_make_project("Default WS Project", str(project_root)), + ) + + 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([], default=None), + ), + patch.object( + ProjectClient, + "create_project", + new_callable=AsyncMock, + return_value=fake_status, + ), + patch( + "basic_memory.mcp.project_context.invalidate_workspace_project_index", + new_callable=AsyncMock, + ), + ): + await create_memory_project( + project_name="Default WS Project", + project_path=str(project_root), + ) + + assert captured["workspace"] is None + + # --- Cloud merge tests ---