"""Unified project resolution across MCP, API, and CLI.
This module provides a single canonical implementation of project resolution
logic, eliminating duplicated decision trees across the codebase.
The resolution follows a three-tier hierarchy:
1. Constrained mode: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
2. Explicit parameter: Project passed directly to operation
3. Default project: Used when default_project_mode=true (lowest priority)
In cloud mode, project is required unless discovery mode is explicitly allowed.
"""
import os
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional
from loguru import logger
class ResolutionMode(Enum):
"""How the project was resolved."""
CLOUD_EXPLICIT = auto() # Explicit project in cloud mode
CLOUD_DISCOVERY = auto() # Discovery mode allowed in cloud (no project)
ENV_CONSTRAINT = auto() # BASIC_MEMORY_MCP_PROJECT env var
EXPLICIT = auto() # Explicit project parameter
DEFAULT = auto() # default_project with default_project_mode=true
NONE = auto() # No resolution possible
@dataclass(frozen=True)
class ResolvedProject:
"""Result of project resolution.
Attributes:
project: The resolved project name, or None if not resolved
mode: How the project was resolved
reason: Human-readable explanation of resolution
"""
project: Optional[str]
mode: ResolutionMode
reason: str
@property
def is_resolved(self) -> bool:
"""Whether a project was successfully resolved."""
return self.project is not None
@property
def is_discovery_mode(self) -> bool:
"""Whether we're in discovery mode (no specific project)."""
return self.mode == ResolutionMode.CLOUD_DISCOVERY or (
self.mode == ResolutionMode.NONE and self.project is None
)
@dataclass
class ProjectResolver:
"""Unified project resolution logic.
Resolves the effective project given requested project, environment
constraints, and configuration settings.
This is the single canonical implementation of project resolution,
used by MCP tools, API routes, and CLI commands.
Args:
cloud_mode: Whether running in cloud mode (project required)
default_project_mode: Whether to use default project when not specified
default_project: The default project name
constrained_project: Optional env-constrained project override
(typically from BASIC_MEMORY_MCP_PROJECT)
"""
cloud_mode: bool = False
default_project_mode: bool = False
default_project: Optional[str] = None
constrained_project: Optional[str] = None
@classmethod
def from_env(
cls,
cloud_mode: bool = False,
default_project_mode: bool = False,
default_project: Optional[str] = None,
) -> "ProjectResolver":
"""Create resolver with constrained_project from environment.
Args:
cloud_mode: Whether running in cloud mode
default_project_mode: Whether to use default project when not specified
default_project: The default project name
Returns:
ProjectResolver configured with current environment
"""
constrained = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
return cls(
cloud_mode=cloud_mode,
default_project_mode=default_project_mode,
default_project=default_project,
constrained_project=constrained,
)
def resolve(
self,
project: Optional[str] = None,
allow_discovery: bool = False,
) -> ResolvedProject:
"""Resolve project using the three-tier hierarchy.
Resolution order:
1. Cloud mode check (project required unless discovery allowed)
2. Constrained project from env var (highest priority in local mode)
3. Explicit project parameter
4. Default project if default_project_mode=true
Args:
project: Optional explicit project parameter
allow_discovery: If True, allows returning None in cloud mode
for discovery operations (e.g., recent_activity across projects)
Returns:
ResolvedProject with project name, resolution mode, and reason
Raises:
ValueError: If in cloud mode and no project specified (unless discovery allowed)
"""
# --- Cloud Mode Handling ---
# In cloud mode, project is required unless discovery is explicitly allowed
if self.cloud_mode:
if project:
logger.debug(f"Cloud mode: using explicit project '{project}'")
return ResolvedProject(
project=project,
mode=ResolutionMode.CLOUD_EXPLICIT,
reason=f"Explicit project in cloud mode: {project}",
)
elif allow_discovery:
logger.debug("Cloud mode: discovery mode allowed, no project required")
return ResolvedProject(
project=None,
mode=ResolutionMode.CLOUD_DISCOVERY,
reason="Discovery mode enabled in cloud",
)
else:
raise ValueError("No project specified. Project is required for cloud mode.")
# --- Local Mode: Three-Tier Hierarchy ---
# Priority 1: CLI constraint overrides everything
if self.constrained_project:
logger.debug(f"Using CLI constrained project: {self.constrained_project}")
return ResolvedProject(
project=self.constrained_project,
mode=ResolutionMode.ENV_CONSTRAINT,
reason=f"Environment constraint: BASIC_MEMORY_MCP_PROJECT={self.constrained_project}",
)
# Priority 2: Explicit project parameter
if project:
logger.debug(f"Using explicit project parameter: {project}")
return ResolvedProject(
project=project,
mode=ResolutionMode.EXPLICIT,
reason=f"Explicit parameter: {project}",
)
# Priority 3: Default project mode
if self.default_project_mode and self.default_project:
logger.debug(f"Using default project from config: {self.default_project}")
return ResolvedProject(
project=self.default_project,
mode=ResolutionMode.DEFAULT,
reason=f"Default project mode: {self.default_project}",
)
# No resolution possible
logger.debug("No project resolution possible")
return ResolvedProject(
project=None,
mode=ResolutionMode.NONE,
reason="No project specified and default_project_mode is disabled",
)
def require_project(
self,
project: Optional[str] = None,
error_message: Optional[str] = None,
) -> ResolvedProject:
"""Resolve project, raising an error if not resolved.
Convenience method for operations that require a project.
Args:
project: Optional explicit project parameter
error_message: Custom error message if project not resolved
Returns:
ResolvedProject (always with a non-None project)
Raises:
ValueError: If project could not be resolved
"""
result = self.resolve(project, allow_discovery=False)
if not result.is_resolved:
msg = error_message or (
"No project specified. Either set 'default_project_mode=true' in config, "
"or provide a 'project' argument."
)
raise ValueError(msg)
return result
__all__ = [
"ProjectResolver",
"ResolvedProject",
"ResolutionMode",
]