feat(mcp): create projects by workspace slug (#789)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-05-03 19:31:55 -05:00
committed by GitHub
parent 05adda1502
commit a661e924df
4 changed files with 367 additions and 3 deletions
@@ -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:
@@ -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
+7 -1
View File
@@ -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": [
+195
View File
@@ -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 ---