From 9508495631460c0b48f20c45b779d67f2de39506 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Wed, 11 Mar 2026 14:43:45 +0100 Subject: [PATCH] Add available meeting intervals across MCP, REST, and A2A --- README.md | 37 +++- app/a2a/agent_card.py | 19 +- app/a2a/router.py | 121 ++++++++++++- app/calendar_agent.py | 203 +++++++++++++++++++++- app/core/__init__.py | 4 + app/core/models.py | 15 ++ app/core/service.py | 22 +++ app/main.py | 68 ++++++++ app/mcp/oauth.py | 7 +- app/mcp/server.py | 22 +++ app/mcp/tools.py | 28 +++ docs/a2a.md | 28 ++- docs/mcp.md | 7 +- docs/security.md | 1 + pyproject.toml | 1 + tests/test_a2a_http_integration.py | 125 ++++++++++++++ tests/test_calendar_meeting_intervals.py | 211 +++++++++++++++++++++++ tests/test_core_meeting_intervals.py | 45 +++++ tests/test_main_http_integration.py | 60 +++++++ tests/test_mcp_meeting_intervals.py | 95 ++++++++++ tests/test_mcp_oauth_http.py | 11 +- tests/test_mcp_tools_auth.py | 99 +++++++++++ tests/test_rest_meeting_intervals.py | 52 ++++++ uv.lock | 14 ++ 24 files changed, 1272 insertions(+), 23 deletions(-) create mode 100644 tests/test_calendar_meeting_intervals.py create mode 100644 tests/test_core_meeting_intervals.py create mode 100644 tests/test_mcp_meeting_intervals.py create mode 100644 tests/test_rest_meeting_intervals.py diff --git a/README.md b/README.md index 9102bf5..aaee3bc 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,19 @@ curl -X POST "http://127.0.0.1:8000/availability" \ If `available` is `true`, there are no busy slots in that range. +### Suggested meeting intervals for AI agents + +```bash +curl -X POST "http://127.0.0.1:8000/meeting-intervals" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-secret" \ + -d '{ + "start": "2026-03-09T08:00:00+01:00", + "end": "2026-03-10T18:00:00+01:00", + "calendar_ids": ["primary"] + }' +``` + ### A2A discovery ```bash @@ -136,7 +149,26 @@ curl -X POST "http://127.0.0.1:8000/a2a/rpc" \ }' ``` -### MCP server (availability tool) +### A2A SendMessage meeting intervals + +```bash +curl -X POST "http://127.0.0.1:8000/a2a/rpc" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-secret" \ + -d '{ + "jsonrpc": "2.0", + "id": "req-2", + "method": "SendMessage", + "params": { + "action": "available_meeting_intervals", + "start": "2026-03-09T08:00:00+01:00", + "end": "2026-03-10T18:00:00+01:00", + "calendar_ids": ["primary"] + } + }' +``` + +### MCP server (availability and scheduling tools) Run MCP on a dedicated port: @@ -152,7 +184,7 @@ MCP streamable HTTP endpoint: http://127.0.0.1:8001/mcp ``` -By default, MCP exposes only `check_availability`. +By default, MCP exposes `check_availability` and `available_meeting_intervals`. To expose internal mutation tools (`scan_mailbox`, `list_unsubscribe_candidates`, `execute_unsubscribe`), set: ```bash @@ -175,6 +207,7 @@ MCP_OAUTH_AUDIENCE=personal-agent-mcp Scopes required per MCP tool: - `check_availability`: `availability:read` +- `available_meeting_intervals`: `available_meeting_intervals:read` - `scan_mailbox`: `mail:scan` - `list_unsubscribe_candidates`: `unsubscribe:read` - `execute_unsubscribe`: `unsubscribe:execute` diff --git a/app/a2a/agent_card.py b/app/a2a/agent_card.py index 15ac737..36f56ee 100644 --- a/app/a2a/agent_card.py +++ b/app/a2a/agent_card.py @@ -41,7 +41,24 @@ def build_agent_card(settings: Settings, request: Request) -> dict[str, Any]: ], "inputModes": ["application/json"], "outputModes": ["application/json"], - } + }, + { + "id": "meeting_intervals.query", + "name": "Available Meeting Intervals", + "description": ( + "Suggests free meeting intervals between two datetimes using " + "Paris scheduling rules and holiday exclusions." + ), + "tags": ["calendar", "scheduling", "meeting"], + "examples": [ + ( + "SendMessage with params.action='available_meeting_intervals', " + "plus params.start/end/calendar_ids." + ) + ], + "inputModes": ["application/json"], + "outputModes": ["application/json"], + }, ], } diff --git a/app/a2a/router.py b/app/a2a/router.py index 9dce953..fc6d973 100644 --- a/app/a2a/router.py +++ b/app/a2a/router.py @@ -21,6 +21,12 @@ router = APIRouter(tags=["a2a"]) SEND_MESSAGE_METHODS = {"SendMessage", "send_message", "messages.send"} PING_METHODS = {"ping", "health.ping", "health/ping"} +DEFAULT_SEND_MESSAGE_ACTION = "check_availability" +MEETING_INTERVALS_ACTION = "available_meeting_intervals" +_ACTION_SCOPE = { + DEFAULT_SEND_MESSAGE_ACTION: "availability:read", + MEETING_INTERVALS_ACTION: "available_meeting_intervals:read", +} @router.get("/.well-known/agent-card.json") @@ -51,14 +57,23 @@ def a2a_rpc( ) if payload.method in SEND_MESSAGE_METHODS: - auth_error = _check_availability_access( + try: + action = _resolve_send_message_action(payload.params) + except ValueError as exc: + return _error_response( + request_id=payload.id, + code=-32602, + message=str(exc), + ) + auth_error = _check_scope_access( x_api_key=x_api_key, authorization=authorization, request_id=payload.id, + required_scope=_ACTION_SCOPE[action], ) if auth_error: return auth_error - return _handle_send_message(payload) + return _handle_send_message(payload, action=action) return _error_response( request_id=payload.id, @@ -74,17 +89,18 @@ def _error_response(request_id: str | int | None, code: int, message: str) -> A2 ) -def _check_availability_access( +def _check_scope_access( *, x_api_key: str | None, authorization: str | None, request_id: str | int | None, + required_scope: str, ) -> A2ARpcResponse | None: try: auth_backend.authenticate( x_api_key=x_api_key, authorization=authorization, - required_scopes={"availability:read"}, + required_scopes={required_scope}, ) except HTTPException as exc: return A2ARpcResponse( @@ -98,9 +114,9 @@ def _check_availability_access( return None -def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse: +def _handle_send_message(payload: A2ARpcRequest, *, action: str) -> A2ARpcResponse: try: - request_payload = _extract_availability_payload(payload.params) + request_payload = _extract_schedule_payload(payload.params) start = _require_string(request_payload, "start") end = _require_string(request_payload, "end") calendar_ids = _parse_calendar_ids(request_payload.get("calendar_ids")) @@ -112,6 +128,8 @@ def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse: ) try: + if action == MEETING_INTERVALS_ACTION: + return _meeting_intervals_response(payload, start, end, calendar_ids) result = core_service.check_availability(start, end, calendar_ids) except ValueError as exc: return _error_response( @@ -126,10 +144,15 @@ def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse: message=str(exc), ) except Exception as exc: + failure_label = ( + "Meeting interval lookup failed" + if action == MEETING_INTERVALS_ACTION + else "Availability lookup failed" + ) return _error_response( request_id=payload.id, code=-32000, - message=f"Availability lookup failed: {exc}", + message=f"{failure_label}: {exc}", ) availability = { @@ -155,7 +178,87 @@ def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse: ) -def _extract_availability_payload(params: dict[str, Any]) -> dict[str, Any]: +def _meeting_intervals_response( + payload: A2ARpcRequest, + start: str, + end: str, + calendar_ids: list[str] | None, +) -> A2ARpcResponse: + result = core_service.available_meeting_intervals(start, end, calendar_ids) + meeting_intervals = { + "start": result.start, + "end": result.end, + "timezone": result.timezone, + "meeting_intervals": [ + { + "start": interval.start, + "end": interval.end, + } + for interval in result.meeting_intervals + ], + "checked_calendars": result.checked_calendars, + } + return A2ARpcResponse( + id=payload.id, + result={ + "type": "available_meeting_intervals.result", + "meeting_intervals": meeting_intervals, + }, + ) + + +def _resolve_send_message_action(params: dict[str, Any]) -> str: + action = _extract_action(params) + if action is None: + return DEFAULT_SEND_MESSAGE_ACTION + normalized = action.strip() + if not normalized: + return DEFAULT_SEND_MESSAGE_ACTION + lowered = normalized.lower() + if lowered in {"check_availability", "availability"}: + return DEFAULT_SEND_MESSAGE_ACTION + if lowered == MEETING_INTERVALS_ACTION: + return MEETING_INTERVALS_ACTION + raise ValueError( + "Unsupported 'action'. Expected 'available_meeting_intervals' or omitted for availability." + ) + + +def _extract_action(params: dict[str, Any]) -> str | None: + if "action" in params and isinstance(params["action"], str): + return params["action"] + + for key in ("input", "arguments", "data"): + nested = params.get(key) + if isinstance(nested, dict): + action = _extract_action(nested) + if action is not None: + return action + elif isinstance(nested, str): + parsed = _parse_json_object(nested) + if parsed is not None: + action = _extract_action(parsed) + if action is not None: + return action + + message = params.get("message") + if isinstance(message, dict): + action = _extract_action(message) + if action is not None: + return action + + messages = params.get("messages") + if isinstance(messages, list): + for item in reversed(messages): + if isinstance(item, dict): + action = _extract_action(item) + if action is not None: + return action + + return None + + +def _extract_schedule_payload(params: dict[str, Any]) -> dict[str, Any]: direct = _dict_with_availability_fields(params) if direct is not None: return direct @@ -188,7 +291,7 @@ def _extract_availability_payload(params: dict[str, Any]) -> dict[str, Any]: return extracted raise ValueError( - "SendMessage requires availability input with 'start' and 'end'. " + "SendMessage requires scheduling input with 'start' and 'end'. " "Supported shapes: params.start/end, params.input.start/end, or message content JSON." ) diff --git a/app/calendar_agent.py b/app/calendar_agent.py index c1083ec..f1f7899 100644 --- a/app/calendar_agent.py +++ b/app/calendar_agent.py @@ -1,8 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import date, datetime, time, timedelta from typing import Any +from zoneinfo import ZoneInfo + +import holidays @dataclass(frozen=True) @@ -14,6 +17,15 @@ class AvailabilityResult: checked_calendars: list[str] +@dataclass(frozen=True) +class MeetingIntervalsResult: + start: str + end: str + timezone: str + meeting_intervals: list[dict[str, str]] + checked_calendars: list[str] + + class CalendarAvailabilityAgent: def __init__(self, calendar_service: Any) -> None: self.calendar_service = calendar_service @@ -56,6 +68,65 @@ class CalendarAvailabilityAgent: checked_calendars=calendars, ) + def get_available_meeting_intervals( + self, start: str, end: str, calendar_ids: list[str] | None = None + ) -> MeetingIntervalsResult: + start_dt = _parse_iso_datetime(start) + end_dt = _parse_iso_datetime(end) + + if end_dt <= start_dt: + raise ValueError("end must be after start.") + if end_dt - start_dt > timedelta(days=90): + raise ValueError("time range cannot exceed 90 days.") + + calendars = calendar_ids or ["primary"] + query_body: dict[str, Any] = { + "timeMin": start_dt.isoformat(), + "timeMax": end_dt.isoformat(), + "items": [{"id": calendar_id} for calendar_id in calendars], + } + freebusy = self.calendar_service.freebusy().query(body=query_body).execute() + calendars_payload = freebusy.get("calendars", {}) + + paris_tz = ZoneInfo("Europe/Paris") + start_paris = start_dt.astimezone(paris_tz) + end_paris = end_dt.astimezone(paris_tz) + + merged_busy = _merge_intervals( + _collect_busy_intervals( + calendars_payload=calendars_payload, + start_bound=start_paris, + end_bound=end_paris, + timezone=paris_tz, + ) + ) + holiday_dates = _holiday_dates(start_paris.date(), end_paris.date()) + allowed_windows = _build_allowed_windows(start_paris, end_paris, holiday_dates, paris_tz) + + meeting_intervals: list[dict[str, str]] = [] + for window_start, window_end in allowed_windows: + for interval_start, interval_end in _subtract_busy( + window_start=window_start, + window_end=window_end, + busy_intervals=merged_busy, + ): + if interval_end - interval_start < timedelta(minutes=30): + continue + meeting_intervals.append( + { + "start": interval_start.isoformat(), + "end": interval_end.isoformat(), + } + ) + + return MeetingIntervalsResult( + start=start_dt.isoformat(), + end=end_dt.isoformat(), + timezone="Europe/Paris", + meeting_intervals=meeting_intervals, + checked_calendars=calendars, + ) + def _parse_iso_datetime(value: str) -> datetime: normalized = value.strip() @@ -66,3 +137,133 @@ def _parse_iso_datetime(value: str) -> datetime: if parsed.tzinfo is None: raise ValueError("datetime must include a timezone offset, for example +01:00.") return parsed + + +def _collect_busy_intervals( + *, + calendars_payload: Any, + start_bound: datetime, + end_bound: datetime, + timezone: ZoneInfo, +) -> list[tuple[datetime, datetime]]: + if not isinstance(calendars_payload, dict): + return [] + + intervals: list[tuple[datetime, datetime]] = [] + for value in calendars_payload.values(): + if not isinstance(value, dict): + continue + raw_busy_slots = value.get("busy", []) + if not isinstance(raw_busy_slots, list): + continue + for raw_busy_slot in raw_busy_slots: + if not isinstance(raw_busy_slot, dict): + continue + busy_start = raw_busy_slot.get("start") + busy_end = raw_busy_slot.get("end") + if not isinstance(busy_start, str) or not isinstance(busy_end, str): + continue + + start_paris = _parse_iso_datetime(busy_start).astimezone(timezone) + end_paris = _parse_iso_datetime(busy_end).astimezone(timezone) + clipped_start = max(start_paris, start_bound) + clipped_end = min(end_paris, end_bound) + if clipped_end <= clipped_start: + continue + intervals.append((clipped_start, clipped_end)) + return intervals + + +def _merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]: + if not intervals: + return [] + + sorted_intervals = sorted(intervals, key=lambda interval: (interval[0], interval[1])) + merged: list[tuple[datetime, datetime]] = [sorted_intervals[0]] + for current_start, current_end in sorted_intervals[1:]: + last_start, last_end = merged[-1] + if current_start <= last_end: + merged[-1] = (last_start, max(last_end, current_end)) + continue + merged.append((current_start, current_end)) + return merged + + +def _build_allowed_windows( + start: datetime, + end: datetime, + holiday_dates: set[date], + timezone: ZoneInfo, +) -> list[tuple[datetime, datetime]]: + windows: list[tuple[datetime, datetime]] = [] + for day in _date_range(start.date(), end.date()): + if day in holiday_dates: + continue + daily_window = _daily_allowed_window(day) + if daily_window is None: + continue + + raw_start, raw_end = daily_window + day_start = datetime.combine(day, raw_start, tzinfo=timezone) + day_end = datetime.combine(day, raw_end, tzinfo=timezone) + window_start = max(start, day_start) + window_end = min(end, day_end) + if window_end <= window_start: + continue + windows.append((window_start, window_end)) + return windows + + +def _daily_allowed_window(day: date) -> tuple[time, time] | None: + weekday = day.weekday() + if weekday == 6: + return None + if weekday == 5: + return time(hour=9, minute=30), time(hour=12, minute=0) + return time(hour=8, minute=30), time(hour=21, minute=30) + + +def _subtract_busy( + *, + window_start: datetime, + window_end: datetime, + busy_intervals: list[tuple[datetime, datetime]], +) -> list[tuple[datetime, datetime]]: + free_intervals: list[tuple[datetime, datetime]] = [] + cursor = window_start + for busy_start, busy_end in busy_intervals: + if busy_end <= cursor: + continue + if busy_start >= window_end: + break + if busy_start > cursor: + free_end = min(busy_start, window_end) + if free_end > cursor: + free_intervals.append((cursor, free_end)) + cursor = max(cursor, busy_end) + if cursor >= window_end: + break + if cursor < window_end: + free_intervals.append((cursor, window_end)) + return free_intervals + + +def _holiday_dates(start_day: date, end_day: date) -> set[date]: + years = range(start_day.year, end_day.year + 1) + france_holidays = holidays.country_holidays("FR", years=years, observed=False) + quebec_holidays = holidays.country_holidays( + "CA", + subdiv="QC", + years=years, + observed=False, + ) + return set(france_holidays.keys()) | set(quebec_holidays.keys()) + + +def _date_range(start_day: date, end_day: date) -> list[date]: + current_day = start_day + days: list[date] = [] + while current_day <= end_day: + days.append(current_day) + current_day += timedelta(days=1) + return days diff --git a/app/core/__init__.py b/app/core/__init__.py index da76f69..822136b 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,6 +1,8 @@ from app.core.models import ( CoreAvailabilityResult, CoreBusySlot, + CoreMeetingInterval, + CoreMeetingIntervalsResult, CoreMailingListCandidate, CoreMethodExecution, CoreScanResult, @@ -16,6 +18,8 @@ __all__ = [ "CoreScanResult", "CoreAvailabilityResult", "CoreBusySlot", + "CoreMeetingInterval", + "CoreMeetingIntervalsResult", "CoreUnsubscribeDigestResult", "CoreUnsubscribeMethod", "CoreMailingListCandidate", diff --git a/app/core/models.py b/app/core/models.py index f3b73a8..df7a8fa 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -29,6 +29,21 @@ class CoreAvailabilityResult: checked_calendars: list[str] +@dataclass(frozen=True) +class CoreMeetingInterval: + start: str + end: str + + +@dataclass(frozen=True) +class CoreMeetingIntervalsResult: + start: str + end: str + timezone: str + meeting_intervals: list[CoreMeetingInterval] + checked_calendars: list[str] + + @dataclass(frozen=True) class CoreUnsubscribeDigestResult: scanned_messages: int diff --git a/app/core/service.py b/app/core/service.py index eae4215..23517d8 100644 --- a/app/core/service.py +++ b/app/core/service.py @@ -17,6 +17,8 @@ from app.unsubscribe_hil_agent import ( from app.core.models import ( CoreAvailabilityResult, CoreBusySlot, + CoreMeetingInterval, + CoreMeetingIntervalsResult, CoreMailingListCandidate, CoreMethodExecution, CoreScanResult, @@ -72,6 +74,26 @@ class CoreAgentService: checked_calendars=result.checked_calendars, ) + def available_meeting_intervals( + self, start: str, end: str, calendar_ids: list[str] | None + ) -> CoreMeetingIntervalsResult: + calendar_service = build_calendar_service(self.settings) + availability_agent = CalendarAvailabilityAgent(calendar_service=calendar_service) + result = availability_agent.get_available_meeting_intervals(start, end, calendar_ids) + return CoreMeetingIntervalsResult( + start=result.start, + end=result.end, + timezone=result.timezone, + meeting_intervals=[ + CoreMeetingInterval( + start=interval["start"], + end=interval["end"], + ) + for interval in result.meeting_intervals + ], + checked_calendars=result.checked_calendars, + ) + def scan_unsubscribe_digest(self, max_results: int) -> CoreUnsubscribeDigestResult: bounded_max_results = max(1, min(max_results, 500)) gmail_service = build_gmail_service(self.settings) diff --git a/app/main.py b/app/main.py index b02a8c7..a114769 100644 --- a/app/main.py +++ b/app/main.py @@ -55,6 +55,25 @@ class AvailabilityResponse(BaseModel): checked_calendars: list[str] +class MeetingIntervalsRequest(BaseModel): + start: str + end: str + calendar_ids: list[str] | None = None + + +class MeetingInterval(BaseModel): + start: str + end: str + + +class MeetingIntervalsResponse(BaseModel): + start: str + end: str + timezone: str + meeting_intervals: list[MeetingInterval] + checked_calendars: list[str] + + class UnsubscribeDigestResponse(BaseModel): scanned_messages: int extracted_unique_links: int @@ -155,6 +174,28 @@ def _run_unsubscribe_digest_once(max_results: int) -> UnsubscribeDigestResponse: ) +def _run_meeting_intervals_once( + start: str, + end: str, + calendar_ids: list[str] | None, +) -> MeetingIntervalsResponse: + result = core_service.available_meeting_intervals( + start=start, + end=end, + calendar_ids=calendar_ids, + ) + return MeetingIntervalsResponse( + start=result.start, + end=result.end, + timezone=result.timezone, + meeting_intervals=[ + MeetingInterval.model_validate(interval, from_attributes=True) + for interval in result.meeting_intervals + ], + checked_calendars=result.checked_calendars, + ) + + def _run_unsubscribe_candidates_once(max_results: int) -> UnsubscribeCandidatesResponse: result = core_service.list_unsubscribe_candidates(max_results=max_results) return UnsubscribeCandidatesResponse( @@ -418,6 +459,33 @@ async def availability(request: AvailabilityRequest) -> AvailabilityResponse: ) from exc +@app.post( + "/meeting-intervals", + response_model=MeetingIntervalsResponse, + dependencies=[Depends(require_scope("available_meeting_intervals:read"))], +) +async def meeting_intervals(request: MeetingIntervalsRequest) -> MeetingIntervalsResponse: + try: + return await asyncio.to_thread( + _run_meeting_intervals_once, + request.start, + request.end, + request.calendar_ids, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(exc), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Meeting interval lookup failed: {exc}", + ) from exc + + @app.post( "/unsubscribe-digest", response_model=UnsubscribeDigestResponse, diff --git a/app/mcp/oauth.py b/app/mcp/oauth.py index 3f767db..6f96466 100644 --- a/app/mcp/oauth.py +++ b/app/mcp/oauth.py @@ -10,7 +10,7 @@ from mcp.server.auth.settings import AuthSettings from app.config import Settings from app.security import AuthBackend -_MCP_BASE_SCOPE = "availability:read" +_MCP_ALWAYS_ENABLED_SCOPES = ["availability:read", "available_meeting_intervals:read"] def resolve_mcp_auth_mode(settings: Settings) -> str: @@ -20,7 +20,7 @@ def resolve_mcp_auth_mode(settings: Settings) -> str: def mcp_supported_scopes(settings: Settings) -> list[str]: - scopes = [_MCP_BASE_SCOPE] + scopes = list(_MCP_ALWAYS_ENABLED_SCOPES) if settings.mcp_enable_mutation_tools: scopes.extend(["mail:scan", "unsubscribe:read", "unsubscribe:execute"]) return scopes @@ -34,7 +34,8 @@ def build_mcp_oauth_auth_settings(settings: Settings) -> AuthSettings: return AuthSettings( issuer_url=settings.mcp_oauth_issuer, resource_server_url=settings.mcp_resource_server_url, - required_scopes=[_MCP_BASE_SCOPE], + # Leave transport-level scopes unset and enforce scope checks per tool. + required_scopes=None, ) diff --git a/app/mcp/server.py b/app/mcp/server.py index 3e257e5..c6f48b4 100644 --- a/app/mcp/server.py +++ b/app/mcp/server.py @@ -12,6 +12,7 @@ from app.mcp.oauth import ( resolve_mcp_auth_mode, ) from app.mcp.tools import ( + available_meeting_intervals as available_meeting_intervals_impl, check_availability as check_availability_impl, execute_unsubscribe as execute_unsubscribe_impl, list_unsubscribe_candidates as list_unsubscribe_candidates_impl, @@ -32,6 +33,20 @@ def check_availability( ) +def available_meeting_intervals( + start: str, + end: str, + calendar_ids: list[str] | None = None, + ctx: Context | None = None, +) -> dict[str, object]: + return available_meeting_intervals_impl( + start=start, + end=end, + calendar_ids=calendar_ids, + ctx=ctx, + ) + + def scan_mailbox(max_results: int = 100, ctx: Context | None = None) -> dict[str, object]: return scan_mailbox_impl(max_results=max_results, ctx=ctx) @@ -82,6 +97,13 @@ def _register_tools(server: FastMCP, settings: Settings) -> None: check_availability, description="Check Google Calendar availability for a time range.", ) + server.add_tool( + available_meeting_intervals, + description=( + "Suggest free meeting intervals between two datetimes " + "using Paris scheduling rules and holiday exclusions." + ), + ) if settings.mcp_enable_mutation_tools: server.add_tool( diff --git a/app/mcp/tools.py b/app/mcp/tools.py index a4badf6..5339fae 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -42,6 +42,34 @@ def check_availability( } +def available_meeting_intervals( + start: str, + end: str, + calendar_ids: list[str] | None = None, + ctx: Context | None = None, +) -> dict[str, Any]: + """Return free meeting intervals between two datetimes using Paris scheduling rules.""" + _require_scope(ctx, "available_meeting_intervals:read") + result = core_service.available_meeting_intervals( + start=start, + end=end, + calendar_ids=calendar_ids, + ) + return { + "start": result.start, + "end": result.end, + "timezone": result.timezone, + "meeting_intervals": [ + { + "start": interval.start, + "end": interval.end, + } + for interval in result.meeting_intervals + ], + "checked_calendars": result.checked_calendars, + } + + def scan_mailbox(max_results: int = 100, ctx: Context | None = None) -> dict[str, Any]: """Scan inbox emails and classify/move them according to current routing rules.""" _require_scope(ctx, "mail:scan") diff --git a/docs/a2a.md b/docs/a2a.md index 178c795..657f418 100644 --- a/docs/a2a.md +++ b/docs/a2a.md @@ -22,7 +22,7 @@ When using Docker Compose: Implemented methods: - `ping` / `health.ping` / `health/ping` -- `SendMessage` (availability only) +- `SendMessage` (availability + meeting intervals) ## Authentication @@ -34,7 +34,8 @@ The A2A adapter uses the same auth backend as REST: Required scope for `SendMessage`: -- `availability:read` +- `availability:read` (default when `action` omitted) +- `available_meeting_intervals:read` (when `action=available_meeting_intervals`) ## Request shape for SendMessage @@ -52,6 +53,7 @@ Accepted locations: Optional: +- `action`: `available_meeting_intervals` to request interval suggestions (omit for default availability check) - `calendar_ids`: array of calendar ids (defaults to `["primary"]`) ## Smoke tests @@ -80,6 +82,25 @@ curl -X POST "http://127.0.0.1:8000/a2a/rpc" \ }' ``` +Meeting intervals: + +```bash +curl -X POST "http://127.0.0.1:8000/a2a/rpc" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $AGENT_API_KEY" \ + -d '{ + "jsonrpc":"2.0", + "id":"req-2", + "method":"SendMessage", + "params":{ + "action":"available_meeting_intervals", + "start":"2026-03-10T08:00:00+01:00", + "end":"2026-03-10T18:00:00+01:00", + "calendar_ids":["primary"] + } + }' +``` + ## Error mapping - `-32600`: invalid JSON-RPC request @@ -93,7 +114,7 @@ curl -X POST "http://127.0.0.1:8000/a2a/rpc" \ If you get `-32001`: - Verify `AUTH_MODE` -- Verify API key/JWT and scope `availability:read` +- Verify API key/JWT and required scope for the requested action If you get `-32602`: @@ -104,4 +125,3 @@ If you get `-32000` with OAuth file errors: - Check `GOOGLE_CLIENT_SECRETS_FILE` path - Check `GOOGLE_TOKEN_FILE` path - diff --git a/docs/mcp.md b/docs/mcp.md index 6349a8c..713f0c0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -29,6 +29,7 @@ Docker Compose service: Always enabled: - `check_availability` +- `available_meeting_intervals` Optional mutation tools (disabled by default): @@ -70,10 +71,12 @@ When OAuth mode is enabled, FastMCP exposes protected resource metadata at: - `/.well-known/oauth-protected-resource/mcp` and returns OAuth-compliant `WWW-Authenticate: Bearer ...` challenges for unauthenticated requests. +Scope authorization is enforced at tool level (per required scope above). Required scopes: - `check_availability`: `availability:read` +- `available_meeting_intervals`: `available_meeting_intervals:read` - `scan_mailbox`: `mail:scan` - `list_unsubscribe_candidates`: `unsubscribe:read` - `execute_unsubscribe`: `unsubscribe:execute` @@ -97,8 +100,8 @@ PY Expected output by mode: -- default: `['check_availability']` -- with `MCP_ENABLE_MUTATION_TOOLS=true`: all four tools +- default: `['check_availability', 'available_meeting_intervals']` +- with `MCP_ENABLE_MUTATION_TOOLS=true`: all five tools ## Protocol notes diff --git a/docs/security.md b/docs/security.md index 6701c4d..d46e1a5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,6 +37,7 @@ Internal traffic: ## Scope matrix - `availability:read`: availability access +- `available_meeting_intervals:read`: suggested meeting intervals access - `mail:scan`: inbox scan and triage - `unsubscribe:read`: candidate discovery - `unsubscribe:execute`: unsubscribe execution diff --git a/pyproject.toml b/pyproject.toml index 1e71caa..b9f0535 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.11" dependencies = [ "apscheduler", "fastapi", + "holidays", "mcp", "pytest", "google-api-python-client", diff --git a/tests/test_a2a_http_integration.py b/tests/test_a2a_http_integration.py index 649845c..f7581ed 100644 --- a/tests/test_a2a_http_integration.py +++ b/tests/test_a2a_http_integration.py @@ -4,6 +4,7 @@ from dataclasses import replace from types import SimpleNamespace from fastapi import FastAPI +from fastapi import HTTPException from fastapi.testclient import TestClient import app.a2a.router as a2a_module @@ -27,6 +28,39 @@ class _DummyCoreService: checked_calendars=checked, ) + def available_meeting_intervals( + self, + start: str, + end: str, + calendar_ids: list[str] | None, + ) -> SimpleNamespace: + checked = calendar_ids or ["primary"] + return SimpleNamespace( + start=start, + end=end, + timezone="Europe/Paris", + meeting_intervals=[ + SimpleNamespace( + start="2026-03-10T08:30:00+01:00", + end="2026-03-10T09:30:00+01:00", + ) + ], + checked_calendars=checked, + ) + + +class _ScopeAwareAuthBackend: + def authenticate( + self, + *, + x_api_key: str | None, + authorization: str | None, + required_scopes: set[str], + ) -> None: + if required_scopes == {"available_meeting_intervals:read"}: + return None + raise HTTPException(status_code=403, detail="insufficient_scope") + def _build_test_app() -> FastAPI: app = FastAPI() @@ -110,3 +144,94 @@ def test_a2a_send_message_with_api_key(monkeypatch) -> None: assert payload["error"] is None assert payload["result"]["availability"]["available"] is True assert payload["result"]["availability"]["checked_calendars"] == ["primary"] + + +def test_a2a_send_message_available_meeting_intervals_with_api_key(monkeypatch) -> None: + auth_settings = replace( + get_settings(), + auth_mode="api_key", + agent_api_key="integration-key", + auth_jwt_secret="", + ) + monkeypatch.setattr(a2a_module, "auth_backend", AuthBackend(auth_settings)) + monkeypatch.setattr(a2a_module, "core_service", _DummyCoreService()) + app = _build_test_app() + + with TestClient(app) as client: + response = client.post( + "/a2a/rpc", + headers={"X-API-Key": "integration-key"}, + json={ + "jsonrpc": "2.0", + "id": "r3", + "method": "SendMessage", + "params": { + "action": "available_meeting_intervals", + "start": "2026-03-10T08:00:00+01:00", + "end": "2026-03-10T10:00:00+01:00", + "calendar_ids": ["primary"], + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["error"] is None + assert payload["result"]["type"] == "available_meeting_intervals.result" + assert payload["result"]["meeting_intervals"]["timezone"] == "Europe/Paris" + assert payload["result"]["meeting_intervals"]["meeting_intervals"] == [ + { + "start": "2026-03-10T08:30:00+01:00", + "end": "2026-03-10T09:30:00+01:00", + } + ] + + +def test_a2a_send_message_meeting_intervals_scope_enforced(monkeypatch) -> None: + monkeypatch.setattr(a2a_module, "auth_backend", _ScopeAwareAuthBackend()) + monkeypatch.setattr(a2a_module, "core_service", _DummyCoreService()) + app = _build_test_app() + + with TestClient(app) as client: + response = client.post( + "/a2a/rpc", + json={ + "jsonrpc": "2.0", + "id": "r4", + "method": "SendMessage", + "params": { + "action": "available_meeting_intervals", + "start": "2026-03-10T08:00:00+01:00", + "end": "2026-03-10T10:00:00+01:00", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["error"] is None + assert payload["result"]["type"] == "available_meeting_intervals.result" + + +def test_a2a_send_message_defaults_to_availability_scope(monkeypatch) -> None: + monkeypatch.setattr(a2a_module, "auth_backend", _ScopeAwareAuthBackend()) + monkeypatch.setattr(a2a_module, "core_service", _DummyCoreService()) + app = _build_test_app() + + with TestClient(app) as client: + response = client.post( + "/a2a/rpc", + json={ + "jsonrpc": "2.0", + "id": "r5", + "method": "SendMessage", + "params": { + "start": "2026-03-10T08:00:00+01:00", + "end": "2026-03-10T10:00:00+01:00", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["error"]["code"] == -32001 diff --git a/tests/test_calendar_meeting_intervals.py b/tests/test_calendar_meeting_intervals.py new file mode 100644 index 0000000..38cc068 --- /dev/null +++ b/tests/test_calendar_meeting_intervals.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +import app.calendar_agent as calendar_module +from app.calendar_agent import CalendarAvailabilityAgent + + +class _FakeFreeBusy: + def __init__(self, payload: dict[str, object]) -> None: + self.payload = payload + + def query(self, body: dict[str, object]) -> _FakeFreeBusy: + return self + + def execute(self) -> dict[str, object]: + return self.payload + + +class _FakeCalendarService: + def __init__(self, payload: dict[str, object]) -> None: + self._freebusy = _FakeFreeBusy(payload) + + def freebusy(self) -> _FakeFreeBusy: + return self._freebusy + + +def _build_agent(payload: dict[str, object]) -> CalendarAvailabilityAgent: + return CalendarAvailabilityAgent(calendar_service=_FakeCalendarService(payload)) + + +def test_weekday_boundaries_and_busy_subtraction() -> None: + agent = _build_agent( + { + "calendars": { + "primary": { + "busy": [ + { + "start": "2026-03-09T09:00:00+01:00", + "end": "2026-03-09T10:00:00+01:00", + } + ] + }, + "work": { + "busy": [ + { + "start": "2026-03-09T09:30:00+01:00", + "end": "2026-03-09T11:00:00+01:00", + }, + { + "start": "2026-03-09T12:00:00+01:00", + "end": "2026-03-09T12:20:00+01:00", + }, + ] + }, + } + } + ) + + result = agent.get_available_meeting_intervals( + start="2026-03-09T07:00:00+01:00", + end="2026-03-09T22:00:00+01:00", + calendar_ids=["primary", "work"], + ) + + assert result.timezone == "Europe/Paris" + assert result.checked_calendars == ["primary", "work"] + assert result.meeting_intervals == [ + { + "start": "2026-03-09T08:30:00+01:00", + "end": "2026-03-09T09:00:00+01:00", + }, + { + "start": "2026-03-09T11:00:00+01:00", + "end": "2026-03-09T12:00:00+01:00", + }, + { + "start": "2026-03-09T12:20:00+01:00", + "end": "2026-03-09T21:30:00+01:00", + }, + ] + + +def test_saturday_window_only() -> None: + agent = _build_agent({"calendars": {"primary": {"busy": []}}}) + result = agent.get_available_meeting_intervals( + start="2026-03-14T08:00:00+01:00", + end="2026-03-14T13:00:00+01:00", + calendar_ids=None, + ) + + assert result.meeting_intervals == [ + { + "start": "2026-03-14T09:30:00+01:00", + "end": "2026-03-14T12:00:00+01:00", + } + ] + + +def test_sunday_excluded() -> None: + agent = _build_agent({"calendars": {"primary": {"busy": []}}}) + result = agent.get_available_meeting_intervals( + start="2026-03-15T08:00:00+01:00", + end="2026-03-15T14:00:00+01:00", + calendar_ids=None, + ) + + assert result.meeting_intervals == [] + + +def test_france_holiday_excluded(monkeypatch) -> None: + def _fake_country_holidays( + country: str, + *, + years: range, + observed: bool, + subdiv: str | None = None, + ) -> dict[date, str]: + assert observed is False + if country == "FR": + return {date(2026, 7, 14): "Bastille Day"} + if country == "CA" and subdiv == "QC": + return {} + return {} + + monkeypatch.setattr(calendar_module.holidays, "country_holidays", _fake_country_holidays) + agent = _build_agent({"calendars": {"primary": {"busy": []}}}) + + result = agent.get_available_meeting_intervals( + start="2026-07-14T08:00:00+02:00", + end="2026-07-14T22:00:00+02:00", + calendar_ids=None, + ) + + assert result.meeting_intervals == [] + + +def test_quebec_holiday_excluded(monkeypatch) -> None: + def _fake_country_holidays( + country: str, + *, + years: range, + observed: bool, + subdiv: str | None = None, + ) -> dict[date, str]: + assert observed is False + if country == "FR": + return {} + if country == "CA" and subdiv == "QC": + return {date(2026, 6, 24): "Saint-Jean-Baptiste Day"} + return {} + + monkeypatch.setattr(calendar_module.holidays, "country_holidays", _fake_country_holidays) + agent = _build_agent({"calendars": {"primary": {"busy": []}}}) + + result = agent.get_available_meeting_intervals( + start="2026-06-24T08:00:00+02:00", + end="2026-06-24T22:00:00+02:00", + calendar_ids=None, + ) + + assert result.meeting_intervals == [] + + +def test_intervals_shorter_than_30_minutes_are_filtered() -> None: + agent = _build_agent( + { + "calendars": { + "primary": { + "busy": [ + { + "start": "2026-03-09T08:40:00+01:00", + "end": "2026-03-09T09:30:00+01:00", + } + ] + } + } + } + ) + + result = agent.get_available_meeting_intervals( + start="2026-03-09T08:30:00+01:00", + end="2026-03-09T09:30:00+01:00", + calendar_ids=None, + ) + + assert result.meeting_intervals == [] + + +def test_range_greater_than_90_days_rejected() -> None: + agent = _build_agent({"calendars": {"primary": {"busy": []}}}) + + with pytest.raises(ValueError, match="cannot exceed 90 days"): + agent.get_available_meeting_intervals( + start="2026-01-01T08:00:00+01:00", + end="2026-04-02T08:00:01+02:00", + calendar_ids=None, + ) + + +def test_requires_timezone_offset() -> None: + agent = _build_agent({"calendars": {"primary": {"busy": []}}}) + + with pytest.raises(ValueError, match="timezone offset"): + agent.get_available_meeting_intervals( + start="2026-03-10T08:00:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=None, + ) diff --git a/tests/test_core_meeting_intervals.py b/tests/test_core_meeting_intervals.py new file mode 100644 index 0000000..cabc085 --- /dev/null +++ b/tests/test_core_meeting_intervals.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import app.core.service as core_module +from app.config import get_settings +from app.core.service import CoreAgentService + + +class _FakeFreeBusy: + def __init__(self, payload: dict[str, object]) -> None: + self.payload = payload + + def query(self, body: dict[str, object]) -> _FakeFreeBusy: + return self + + def execute(self) -> dict[str, object]: + return self.payload + + +class _FakeCalendarService: + def __init__(self, payload: dict[str, object]) -> None: + self._freebusy = _FakeFreeBusy(payload) + + def freebusy(self) -> _FakeFreeBusy: + return self._freebusy + + +def test_core_meeting_intervals_maps_result(monkeypatch) -> None: + fake_service = _FakeCalendarService({"calendars": {"primary": {"busy": []}}}) + monkeypatch.setattr(core_module, "build_calendar_service", lambda _: fake_service) + + service = CoreAgentService(settings=get_settings()) + result = service.available_meeting_intervals( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ) + + assert result.timezone == "Europe/Paris" + assert result.checked_calendars == ["primary"] + assert result.meeting_intervals == [ + core_module.CoreMeetingInterval( + start="2026-03-10T08:30:00+01:00", + end="2026-03-10T10:00:00+01:00", + ) + ] diff --git a/tests/test_main_http_integration.py b/tests/test_main_http_integration.py index bf88e66..31ee76a 100644 --- a/tests/test_main_http_integration.py +++ b/tests/test_main_http_integration.py @@ -41,6 +41,25 @@ class _DummyCoreService: checked_calendars=calendar_ids or ["primary"], ) + def available_meeting_intervals( + self, + start: str, + end: str, + calendar_ids: list[str] | None, + ) -> SimpleNamespace: + return SimpleNamespace( + start=start, + end=end, + timezone="Europe/Paris", + meeting_intervals=[ + SimpleNamespace( + start="2026-03-10T08:30:00+01:00", + end="2026-03-10T09:30:00+01:00", + ) + ], + checked_calendars=calendar_ids or ["primary"], + ) + async def _noop_task() -> None: return None @@ -91,3 +110,44 @@ def test_main_availability_endpoint_rejects_missing_key(monkeypatch) -> None: ) assert response.status_code == 401 + + +def test_main_meeting_intervals_endpoint_with_api_key(monkeypatch) -> None: + _setup_main_test_context(monkeypatch) + + with TestClient(main_module.app) as client: + response = client.post( + "/meeting-intervals", + headers={"X-API-Key": "integration-key"}, + json={ + "start": "2026-03-10T08:00:00+01:00", + "end": "2026-03-10T10:00:00+01:00", + "calendar_ids": ["primary"], + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["timezone"] == "Europe/Paris" + assert payload["meeting_intervals"] == [ + { + "start": "2026-03-10T08:30:00+01:00", + "end": "2026-03-10T09:30:00+01:00", + } + ] + + +def test_main_meeting_intervals_endpoint_rejects_missing_key(monkeypatch) -> None: + _setup_main_test_context(monkeypatch) + + with TestClient(main_module.app) as client: + response = client.post( + "/meeting-intervals", + json={ + "start": "2026-03-10T08:00:00+01:00", + "end": "2026-03-10T10:00:00+01:00", + "calendar_ids": ["primary"], + }, + ) + + assert response.status_code == 401 diff --git a/tests/test_mcp_meeting_intervals.py b/tests/test_mcp_meeting_intervals.py new file mode 100644 index 0000000..d7086e1 --- /dev/null +++ b/tests/test_mcp_meeting_intervals.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from fastapi import Response + +import app.a2a.router as a2a_module +from app.a2a.models import A2ARpcRequest +from app.core.models import CoreMeetingInterval +import app.main as main_module +import app.mcp.server as mcp_server_module +import app.mcp.tools as mcp_tools_module + + +class _DummyCoreService: + def available_meeting_intervals( + self, + start: str, + end: str, + calendar_ids: list[str] | None, + ) -> SimpleNamespace: + checked = calendar_ids or ["primary"] + meeting_intervals = [ + CoreMeetingInterval( + start="2026-03-10T08:30:00+01:00", + end="2026-03-10T09:30:00+01:00", + ) + ] + return SimpleNamespace( + start=start, + end=end, + timezone="Europe/Paris", + meeting_intervals=meeting_intervals, + checked_calendars=checked, + ) + + +class _AllowAuthBackend: + def authenticate( + self, + *, + x_api_key: str | None, + authorization: str | None, + required_scopes: set[str], + ) -> None: + return None + + +def test_meeting_intervals_parity_rest_a2a_mcp(monkeypatch) -> None: + dummy_core = _DummyCoreService() + allow_auth = _AllowAuthBackend() + + monkeypatch.setattr(main_module, "core_service", dummy_core) + monkeypatch.setattr(a2a_module, "core_service", dummy_core) + monkeypatch.setattr(mcp_tools_module, "core_service", dummy_core) + monkeypatch.setattr(a2a_module, "auth_backend", allow_auth) + monkeypatch.setattr(mcp_tools_module, "auth_backend", allow_auth) + + rest_response = asyncio.run( + main_module.meeting_intervals( + main_module.MeetingIntervalsRequest( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ) + ) + ).model_dump() + + a2a_response = a2a_module.a2a_rpc( + A2ARpcRequest( + jsonrpc="2.0", + id="req-1", + method="SendMessage", + params={ + "action": "available_meeting_intervals", + "start": "2026-03-10T08:00:00+01:00", + "end": "2026-03-10T10:00:00+01:00", + "calendar_ids": ["primary"], + }, + ), + Response(), + ) + assert a2a_response.error is None + assert a2a_response.result is not None + a2a_payload = a2a_response.result["meeting_intervals"] + + mcp_payload = mcp_server_module.available_meeting_intervals( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ctx=None, + ) + + assert rest_response == a2a_payload == mcp_payload diff --git a/tests/test_mcp_oauth_http.py b/tests/test_mcp_oauth_http.py index bcf33b4..d56ede8 100644 --- a/tests/test_mcp_oauth_http.py +++ b/tests/test_mcp_oauth_http.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from dataclasses import replace import pytest @@ -43,7 +44,7 @@ def test_mcp_oauth_exposes_protected_resource_metadata() -> None: assert [value.rstrip("/") for value in payload["authorization_servers"]] == [ "https://issuer.example" ] - assert "availability:read" in payload["scopes_supported"] + assert payload.get("scopes_supported") in (None, []) def test_mcp_oauth_requires_bearer_token_with_challenge() -> None: @@ -69,3 +70,11 @@ def test_mcp_oauth_mode_requires_resource_server_url() -> None: ) with pytest.raises(ValueError, match="MCP_RESOURCE_SERVER_URL"): build_mcp_server(settings=settings, token_verifier=_StaticTokenVerifier()) + + +def test_mcp_server_registers_meeting_interval_tool() -> None: + server = build_mcp_server(settings=get_settings()) + tool_names = [tool.name for tool in asyncio.run(server.list_tools())] + + assert "check_availability" in tool_names + assert "available_meeting_intervals" in tool_names diff --git a/tests/test_mcp_tools_auth.py b/tests/test_mcp_tools_auth.py index 1c35709..6647cfd 100644 --- a/tests/test_mcp_tools_auth.py +++ b/tests/test_mcp_tools_auth.py @@ -35,6 +35,25 @@ class _DummyCoreService: failed=0, ) + def available_meeting_intervals( + self, + start: str, + end: str, + calendar_ids: list[str] | None, + ) -> SimpleNamespace: + return SimpleNamespace( + start=start, + end=end, + timezone="Europe/Paris", + meeting_intervals=[ + SimpleNamespace( + start="2026-03-10T08:30:00+01:00", + end="2026-03-10T09:00:00+01:00", + ) + ], + checked_calendars=calendar_ids or ["primary"], + ) + class _DummyCtx: def __init__(self, headers: dict[str, str]) -> None: @@ -100,6 +119,51 @@ def test_mcp_scan_mailbox_requires_mail_scan_scope(monkeypatch) -> None: assert payload["scanned"] == 10 +def test_mcp_available_meeting_intervals_requires_auth(monkeypatch) -> None: + auth_settings = replace( + get_settings(), + auth_mode="api_key", + agent_api_key="mcp-key", + auth_jwt_secret="", + ) + monkeypatch.setattr(mcp_tools_module, "auth_backend", AuthBackend(auth_settings)) + monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService()) + + with pytest.raises(PermissionError): + mcp_tools_module.available_meeting_intervals( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ctx=_DummyCtx(headers={}), + ) + + +def test_mcp_available_meeting_intervals_with_api_key(monkeypatch) -> None: + auth_settings = replace( + get_settings(), + auth_mode="api_key", + agent_api_key="mcp-key", + auth_jwt_secret="", + ) + monkeypatch.setattr(mcp_tools_module, "auth_backend", AuthBackend(auth_settings)) + monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService()) + + payload = mcp_tools_module.available_meeting_intervals( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ctx=_DummyCtx(headers={"x-api-key": "mcp-key"}), + ) + + assert payload["timezone"] == "Europe/Paris" + assert payload["meeting_intervals"] == [ + { + "start": "2026-03-10T08:30:00+01:00", + "end": "2026-03-10T09:00:00+01:00", + } + ] + + def test_mcp_auth_mode_oauth_uses_bearer_token(monkeypatch) -> None: mcp_settings = replace( get_settings(), @@ -135,3 +199,38 @@ def test_mcp_auth_mode_oauth_uses_bearer_token(monkeypatch) -> None: assert payload["available"] is True assert payload["checked_calendars"] == ["primary"] + + +def test_mcp_available_meeting_intervals_oauth_requires_new_scope(monkeypatch) -> None: + mcp_settings = replace( + get_settings(), + auth_mode="api_key", + agent_api_key="api-key-not-used-for-mcp", + auth_jwt_secret="", + mcp_auth_mode="oauth", + mcp_oauth_introspection_url="https://issuer.example/introspect", + mcp_oauth_issuer="https://issuer.example", + mcp_oauth_audience="personal-agent-mcp", + ) + backend = mcp_tools_module._build_mcp_auth_backend(mcp_settings) + monkeypatch.setattr( + backend, + "_introspect_oauth_token", + lambda _: { + "active": True, + "sub": "oauth-agent", + "iss": "https://issuer.example", + "aud": "personal-agent-mcp", + "scope": "availability:read", + }, + ) + monkeypatch.setattr(mcp_tools_module, "auth_backend", backend) + monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService()) + + with pytest.raises(PermissionError, match="available_meeting_intervals:read"): + mcp_tools_module.available_meeting_intervals( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ctx=_DummyCtx(headers={"authorization": "Bearer oauth-token"}), + ) diff --git a/tests/test_rest_meeting_intervals.py b/tests/test_rest_meeting_intervals.py new file mode 100644 index 0000000..acaade6 --- /dev/null +++ b/tests/test_rest_meeting_intervals.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import app.main as main_module + + +class _DummyCoreService: + def available_meeting_intervals( + self, + start: str, + end: str, + calendar_ids: list[str] | None, + ) -> SimpleNamespace: + checked = calendar_ids or ["primary"] + return SimpleNamespace( + start=start, + end=end, + timezone="Europe/Paris", + meeting_intervals=[ + SimpleNamespace( + start="2026-03-10T08:30:00+01:00", + end="2026-03-10T09:30:00+01:00", + ) + ], + checked_calendars=checked, + ) + + +def test_rest_meeting_intervals_adapter_returns_expected_payload(monkeypatch) -> None: + monkeypatch.setattr(main_module, "core_service", _DummyCoreService()) + + response = asyncio.run( + main_module.meeting_intervals( + main_module.MeetingIntervalsRequest( + start="2026-03-10T08:00:00+01:00", + end="2026-03-10T10:00:00+01:00", + calendar_ids=["primary"], + ) + ) + ) + + payload = response.model_dump() + assert payload["timezone"] == "Europe/Paris" + assert payload["checked_calendars"] == ["primary"] + assert payload["meeting_intervals"] == [ + { + "start": "2026-03-10T08:30:00+01:00", + "end": "2026-03-10T09:30:00+01:00", + } + ] diff --git a/uv.lock b/uv.lock index cb0ec1a..8bf8e1a 100644 --- a/uv.lock +++ b/uv.lock @@ -445,6 +445,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "holidays" +version = "0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/e397b5c64a17f198b7b9b719244b1ffb823ac685656e608b70de7a5b59da/holidays-0.92.tar.gz", hash = "sha256:5d716ececf94e0d354ccee255541f6ba702078d7ed17b693262f6446214904a5", size = 844925, upload-time = "2026-03-02T19:33:17.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/00/8ee09c2c671cc7e95c6212d1c15b2b67c2011468f352c21200e18c08e6c0/holidays-0.92-py3-none-any.whl", hash = "sha256:92c192a20d80cd2ddbdf3166d73a9692c59701ded34f6754115b3c849ac60857", size = 1385981, upload-time = "2026-03-02T19:33:15.627Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -822,6 +834,7 @@ dependencies = [ { name = "google-api-python-client" }, { name = "google-auth" }, { name = "google-auth-oauthlib" }, + { name = "holidays" }, { name = "mcp" }, { name = "pytest" }, { name = "python-dotenv" }, @@ -836,6 +849,7 @@ requires-dist = [ { name = "google-api-python-client" }, { name = "google-auth" }, { name = "google-auth-oauthlib" }, + { name = "holidays" }, { name = "mcp" }, { name = "pytest" }, { name = "python-dotenv" },