@@ -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
0 commit comments