mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): create projects by workspace slug (#789)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user