1010
1111import asyncio
1212from contextlib import asynccontextmanager
13- from dataclasses import dataclass
13+ from dataclasses import dataclass , field
1414from typing import AsyncIterator , Awaitable , Callable , Optional , List , Tuple , cast
15+ from uuid import UUID
1516
1617from httpx import AsyncClient
1718from httpx ._types import (
@@ -52,11 +53,12 @@ def qualified_name(self) -> str:
5253
5354@dataclass (frozen = True )
5455class WorkspaceProjectIndex :
55- """Session-local cloud project lookup index keyed by project permalink."""
56+ """Session-local cloud project lookup index keyed by project permalink and external_id ."""
5657
5758 workspaces : tuple [WorkspaceInfo , ...]
5859 entries : tuple [WorkspaceProjectEntry , ...]
5960 entries_by_permalink : dict [str , tuple [WorkspaceProjectEntry , ...]]
61+ entries_by_external_id : dict [str , WorkspaceProjectEntry ] = field (default_factory = dict )
6062 failed_workspaces : tuple [WorkspaceInfo , ...] = ()
6163
6264
@@ -351,10 +353,12 @@ def _build_workspace_project_index(
351353 * ,
352354 failed_workspaces : tuple [WorkspaceInfo , ...] = (),
353355) -> WorkspaceProjectIndex :
354- """Build the permalink lookup table for workspace-project entries."""
356+ """Build the permalink and external_id lookup tables for workspace-project entries."""
355357 grouped : dict [str , list [WorkspaceProjectEntry ]] = {}
358+ by_external_id : dict [str , WorkspaceProjectEntry ] = {}
356359 for entry in entries :
357360 grouped .setdefault (entry .project .permalink , []).append (entry )
361+ by_external_id [entry .project .external_id ] = entry
358362
359363 return WorkspaceProjectIndex (
360364 workspaces = workspaces ,
@@ -363,6 +367,7 @@ def _build_workspace_project_index(
363367 permalink : tuple (items )
364368 for permalink , items in sorted (grouped .items (), key = lambda item : item [0 ])
365369 },
370+ entries_by_external_id = by_external_id ,
366371 failed_workspaces = failed_workspaces ,
367372 )
368373
@@ -549,8 +554,18 @@ async def resolve_workspace_project_identifier(
549554 project : str ,
550555 context : Optional [Context ] = None ,
551556) -> WorkspaceProjectEntry :
552- """Resolve an unqualified or ``<workspace>/<project>`` cloud project identifier ."""
557+ """Resolve a project by external_id (UUID), qualified name, or unqualified name ."""
553558 index = await _ensure_workspace_project_index (context = context )
559+
560+ # Fast path: direct lookup by external_id when the identifier is a UUID
561+ try :
562+ UUID (project )
563+ entry = index .entries_by_external_id .get (project )
564+ if entry :
565+ return entry
566+ except ValueError :
567+ pass
568+
554569 workspace_slug , project_identifier = _split_qualified_project_identifier (project )
555570 project_permalink = generate_permalink (project_identifier )
556571
@@ -617,6 +632,13 @@ async def resolve_workspace_project_identifier(
617632 return cached_matches [0 ]
618633
619634 if len (matches ) > 1 :
635+ # Prefer the project in the default workspace when name is ambiguous
636+ default_match = next (
637+ (entry for entry in matches if entry .workspace .is_default ), None
638+ )
639+ if default_match :
640+ return default_match
641+
620642 choices = _format_qualified_choices (matches )
621643 details = "\n " .join (
622644 f"- { entry .workspace .name } ({ entry .workspace .slug } ): { entry .qualified_name } "
@@ -956,7 +978,6 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -
956978@asynccontextmanager
957979async def get_project_client (
958980 project : Optional [str ] = None ,
959- workspace : Optional [str ] = None ,
960981 context : Optional [Context ] = None ,
961982) -> AsyncIterator [Tuple [AsyncClient , ProjectItem ]]:
962983 """Resolve project, create correctly-routed client, and validate project.
@@ -972,15 +993,8 @@ async def get_project_client(
972993 3. Cloud project mode → resolve project through workspace/project index
973994 4. Otherwise → local ASGI client
974995
975- Workspace resolution priority (when cloud routing):
976- 1. Explicit ``workspace`` parameter
977- 2. Per-project ``workspace_id`` from config
978- 3. Qualified project identifier (``<workspace-slug>/<project>``)
979- 4. Workspace/project index lookup with collision detection
980-
981996 Args:
982- project: Optional explicit project parameter
983- workspace: Optional cloud workspace selector (tenant_id or unique name)
997+ project: Optional explicit project parameter (name, permalink, or external_id UUID)
984998 context: Optional FastMCP context for caching
985999
9861000 Yields:
@@ -1050,37 +1064,14 @@ async def get_project_client(
10501064 project_entry = config .projects .get (resolved_project )
10511065 project_mode = config .get_project_mode (resolved_project )
10521066
1053- # Trigger: workspace provided for a local project (without explicit --cloud)
1054- # Why: workspace selection is a cloud routing concern only
1055- # Outcome: fail fast with a deterministic guidance message
1056- if (
1057- not factory_mode
1058- and project_mode != ProjectMode .CLOUD
1059- and workspace is not None
1060- and not _explicit_routing ()
1061- ):
1062- raise ValueError (
1063- f"Workspace '{ workspace } ' cannot be used with local project '{ resolved_project } '. "
1064- "Workspace selection is only supported for cloud-mode projects."
1065- )
1066-
10671067 if factory_mode or project_mode == ProjectMode .CLOUD or explicit_cloud_routing :
10681068 route_mode = "factory" if factory_mode else "cloud_proxy"
10691069 active_ws : WorkspaceInfo | None = None
10701070 workspace_id : str
10711071 project_for_api = _unqualified_project_identifier (resolved_project )
10721072
1073- # Trigger: a script or config entry pins the tenant explicitly
1074- # Why: explicit tenant configuration remains the escape hatch during migration
1075- # Outcome: route to that workspace, but validate the project name inside it
1076- if workspace is not None :
1077- active_ws = await resolve_workspace_parameter (workspace = workspace , context = context )
1078- workspace_id = active_ws .tenant_id
1079- elif project_entry and project_entry .workspace_id :
1080- # Trigger: the local project config already stores the cloud tenant id.
1081- # Why: routing can send that id directly; requiring workspace discovery here
1082- # would turn a control-plane listing outage into a project routing failure.
1083- # Outcome: preserve project-scoped routing even when discovery is unavailable.
1073+ if project_entry and project_entry .workspace_id :
1074+ # Per-project config stores the cloud tenant id directly
10841075 workspace_id = project_entry .workspace_id
10851076 else :
10861077 resolved_entry = cloud_default_entry
0 commit comments