"""Focus time management tools for Reclaim.ai."""
from typing import Any, Optional
from fastmcp.exceptions import ToolError
from pydantic import ValidationError
from reclaim_mcp.cache import invalidate_cache, ttl_cache
from reclaim_mcp.client import ReclaimClient
from reclaim_mcp.config import get_settings
from reclaim_mcp.exceptions import NotFoundError, RateLimitError, ReclaimError
from reclaim_mcp.models import CalendarEventId, FocusReschedule, FocusSettingsUpdate
from reclaim_mcp.utils import format_validation_errors
def _get_client() -> ReclaimClient:
"""Get a configured Reclaim client."""
settings = get_settings()
return ReclaimClient(settings)
@ttl_cache(ttl=120)
async def get_focus_settings() -> list[dict]:
"""Get current focus time settings for the user.
Returns:
List of focus settings objects (one per focus type).
"""
try:
client = _get_client()
result = await client.get("/api/focus-settings/user")
return result
except RateLimitError as e:
raise ToolError(str(e))
except ReclaimError as e:
raise ToolError(f"Error getting focus settings: {e}")
async def update_focus_settings(
settings_id: int,
min_duration_mins: Optional[int] = None,
ideal_duration_mins: Optional[int] = None,
max_duration_mins: Optional[int] = None,
defense_aggression: Optional[str] = None,
enabled: Optional[bool] = None,
) -> dict:
"""Update focus time settings.
Args:
settings_id: The focus settings ID to update
min_duration_mins: Minimum focus block duration in minutes
ideal_duration_mins: Ideal focus block duration in minutes
max_duration_mins: Maximum focus block duration in minutes
defense_aggression: Protection level (NONE, LOW, MEDIUM, HIGH, MAX)
enabled: Whether focus time is enabled
Returns:
Updated focus settings.
"""
# Validate input using Pydantic model
try:
validated = FocusSettingsUpdate(
min_duration_mins=min_duration_mins,
ideal_duration_mins=ideal_duration_mins,
max_duration_mins=max_duration_mins,
defense_aggression=defense_aggression, # type: ignore[arg-type]
enabled=enabled,
)
except ValidationError as e:
raise ToolError(format_validation_errors(e))
try:
client = _get_client()
update_data: dict[str, Any] = {}
if validated.min_duration_mins is not None:
update_data["minDurationMins"] = validated.min_duration_mins
if validated.ideal_duration_mins is not None:
update_data["idealDurationMins"] = validated.ideal_duration_mins
if validated.max_duration_mins is not None:
update_data["maxDurationMins"] = validated.max_duration_mins
if validated.defense_aggression is not None:
update_data["defenseAggression"] = validated.defense_aggression.value
if validated.enabled is not None:
update_data["enabled"] = validated.enabled
result = await client.patch(f"/api/focus-settings/user/{settings_id}", update_data)
invalidate_cache("get_focus_settings")
return result
except NotFoundError:
raise ToolError(f"Focus settings {settings_id} not found")
except RateLimitError as e:
raise ToolError(str(e))
except ReclaimError as e:
raise ToolError(f"Error updating focus settings: {e}")
async def lock_focus_block(calendar_id: int, event_id: str) -> dict:
"""Lock a focus time block to prevent it from being rescheduled.
Args:
calendar_id: The calendar ID containing the focus block
event_id: The focus block event ID to lock
Returns:
Planner action result with updated event state.
"""
# Validate input using Pydantic model
try:
validated = CalendarEventId(calendar_id=calendar_id, event_id=event_id)
except ValidationError as e:
raise ToolError(format_validation_errors(e))
try:
client = _get_client()
result = await client.post(
f"/api/focus/planner/{validated.calendar_id}/{validated.event_id}/lock",
{},
)
invalidate_cache("list_events")
invalidate_cache("list_personal_events")
return result
except NotFoundError:
# fmt: off
raise ToolError(
f"Focus block {validated.event_id} not found in calendar "
f"{validated.calendar_id}"
)
# fmt: on
except RateLimitError as e:
raise ToolError(str(e))
except ReclaimError as e:
raise ToolError(f"Error locking focus block {validated.event_id}: {e}")
async def unlock_focus_block(calendar_id: int, event_id: str) -> dict:
"""Unlock a focus time block to allow it to be rescheduled.
Args:
calendar_id: The calendar ID containing the focus block
event_id: The focus block event ID to unlock
Returns:
Planner action result with updated event state.
"""
# Validate input using Pydantic model
try:
validated = CalendarEventId(calendar_id=calendar_id, event_id=event_id)
except ValidationError as e:
raise ToolError(format_validation_errors(e))
try:
client = _get_client()
result = await client.post(
f"/api/focus/planner/{validated.calendar_id}/{validated.event_id}/unlock",
{},
)
invalidate_cache("list_events")
invalidate_cache("list_personal_events")
return result
except NotFoundError:
# fmt: off
raise ToolError(
f"Focus block {validated.event_id} not found in calendar "
f"{validated.calendar_id}"
)
# fmt: on
except RateLimitError as e:
raise ToolError(str(e))
except ReclaimError as e:
raise ToolError(f"Error unlocking focus block {validated.event_id}: {e}")
async def reschedule_focus_block(
calendar_id: int,
event_id: str,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> dict:
"""Reschedule a focus time block to a different time.
Args:
calendar_id: The calendar ID containing the focus block
event_id: The focus block event ID to reschedule
start_time: New start time in ISO format (optional, triggers AI reschedule if not provided)
end_time: New end time in ISO format (optional)
Returns:
Planner action result with updated event state.
"""
# Validate input using Pydantic model
try:
validated = FocusReschedule(
calendar_id=calendar_id,
event_id=event_id,
start_time=start_time,
end_time=end_time,
)
except ValidationError as e:
raise ToolError(format_validation_errors(e))
try:
client = _get_client()
payload: dict[str, Any] = {}
if validated.start_time is not None:
payload["start"] = validated.start_time
if validated.end_time is not None:
payload["end"] = validated.end_time
result = await client.post(
f"/api/focus/planner/{validated.calendar_id}/{validated.event_id}/reschedule",
payload,
)
invalidate_cache("list_events")
invalidate_cache("list_personal_events")
return result
except NotFoundError:
# fmt: off
raise ToolError(
f"Focus block {validated.event_id} not found in calendar "
f"{validated.calendar_id}"
)
# fmt: on
except RateLimitError as e:
raise ToolError(str(e))
except ReclaimError as e:
raise ToolError(f"Error rescheduling focus block {validated.event_id}: {e}")