Add available meeting intervals across MCP, REST, and A2A

master
oabrivard 1 week ago
parent eca444f04a
commit 9508495631

@ -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. 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 ### A2A discovery
```bash ```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: Run MCP on a dedicated port:
@ -152,7 +184,7 @@ MCP streamable HTTP endpoint:
http://127.0.0.1:8001/mcp 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: To expose internal mutation tools (`scan_mailbox`, `list_unsubscribe_candidates`, `execute_unsubscribe`), set:
```bash ```bash
@ -175,6 +207,7 @@ MCP_OAUTH_AUDIENCE=personal-agent-mcp
Scopes required per MCP tool: Scopes required per MCP tool:
- `check_availability`: `availability:read` - `check_availability`: `availability:read`
- `available_meeting_intervals`: `available_meeting_intervals:read`
- `scan_mailbox`: `mail:scan` - `scan_mailbox`: `mail:scan`
- `list_unsubscribe_candidates`: `unsubscribe:read` - `list_unsubscribe_candidates`: `unsubscribe:read`
- `execute_unsubscribe`: `unsubscribe:execute` - `execute_unsubscribe`: `unsubscribe:execute`

@ -41,7 +41,24 @@ def build_agent_card(settings: Settings, request: Request) -> dict[str, Any]:
], ],
"inputModes": ["application/json"], "inputModes": ["application/json"],
"outputModes": ["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"],
},
], ],
} }

@ -21,6 +21,12 @@ router = APIRouter(tags=["a2a"])
SEND_MESSAGE_METHODS = {"SendMessage", "send_message", "messages.send"} SEND_MESSAGE_METHODS = {"SendMessage", "send_message", "messages.send"}
PING_METHODS = {"ping", "health.ping", "health/ping"} 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") @router.get("/.well-known/agent-card.json")
@ -51,14 +57,23 @@ def a2a_rpc(
) )
if payload.method in SEND_MESSAGE_METHODS: 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, x_api_key=x_api_key,
authorization=authorization, authorization=authorization,
request_id=payload.id, request_id=payload.id,
required_scope=_ACTION_SCOPE[action],
) )
if auth_error: if auth_error:
return auth_error return auth_error
return _handle_send_message(payload) return _handle_send_message(payload, action=action)
return _error_response( return _error_response(
request_id=payload.id, 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, x_api_key: str | None,
authorization: str | None, authorization: str | None,
request_id: str | int | None, request_id: str | int | None,
required_scope: str,
) -> A2ARpcResponse | None: ) -> A2ARpcResponse | None:
try: try:
auth_backend.authenticate( auth_backend.authenticate(
x_api_key=x_api_key, x_api_key=x_api_key,
authorization=authorization, authorization=authorization,
required_scopes={"availability:read"}, required_scopes={required_scope},
) )
except HTTPException as exc: except HTTPException as exc:
return A2ARpcResponse( return A2ARpcResponse(
@ -98,9 +114,9 @@ def _check_availability_access(
return None return None
def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse: def _handle_send_message(payload: A2ARpcRequest, *, action: str) -> A2ARpcResponse:
try: try:
request_payload = _extract_availability_payload(payload.params) request_payload = _extract_schedule_payload(payload.params)
start = _require_string(request_payload, "start") start = _require_string(request_payload, "start")
end = _require_string(request_payload, "end") end = _require_string(request_payload, "end")
calendar_ids = _parse_calendar_ids(request_payload.get("calendar_ids")) calendar_ids = _parse_calendar_ids(request_payload.get("calendar_ids"))
@ -112,6 +128,8 @@ def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse:
) )
try: try:
if action == MEETING_INTERVALS_ACTION:
return _meeting_intervals_response(payload, start, end, calendar_ids)
result = core_service.check_availability(start, end, calendar_ids) result = core_service.check_availability(start, end, calendar_ids)
except ValueError as exc: except ValueError as exc:
return _error_response( return _error_response(
@ -126,10 +144,15 @@ def _handle_send_message(payload: A2ARpcRequest) -> A2ARpcResponse:
message=str(exc), message=str(exc),
) )
except Exception as exc: except Exception as exc:
failure_label = (
"Meeting interval lookup failed"
if action == MEETING_INTERVALS_ACTION
else "Availability lookup failed"
)
return _error_response( return _error_response(
request_id=payload.id, request_id=payload.id,
code=-32000, code=-32000,
message=f"Availability lookup failed: {exc}", message=f"{failure_label}: {exc}",
) )
availability = { 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) direct = _dict_with_availability_fields(params)
if direct is not None: if direct is not None:
return direct return direct
@ -188,7 +291,7 @@ def _extract_availability_payload(params: dict[str, Any]) -> dict[str, Any]:
return extracted return extracted
raise ValueError( 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." "Supported shapes: params.start/end, params.input.start/end, or message content JSON."
) )

@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import date, datetime, time, timedelta
from typing import Any from typing import Any
from zoneinfo import ZoneInfo
import holidays
@dataclass(frozen=True) @dataclass(frozen=True)
@ -14,6 +17,15 @@ class AvailabilityResult:
checked_calendars: list[str] 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: class CalendarAvailabilityAgent:
def __init__(self, calendar_service: Any) -> None: def __init__(self, calendar_service: Any) -> None:
self.calendar_service = calendar_service self.calendar_service = calendar_service
@ -56,6 +68,65 @@ class CalendarAvailabilityAgent:
checked_calendars=calendars, 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: def _parse_iso_datetime(value: str) -> datetime:
normalized = value.strip() normalized = value.strip()
@ -66,3 +137,133 @@ def _parse_iso_datetime(value: str) -> datetime:
if parsed.tzinfo is None: if parsed.tzinfo is None:
raise ValueError("datetime must include a timezone offset, for example +01:00.") raise ValueError("datetime must include a timezone offset, for example +01:00.")
return parsed 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

@ -1,6 +1,8 @@
from app.core.models import ( from app.core.models import (
CoreAvailabilityResult, CoreAvailabilityResult,
CoreBusySlot, CoreBusySlot,
CoreMeetingInterval,
CoreMeetingIntervalsResult,
CoreMailingListCandidate, CoreMailingListCandidate,
CoreMethodExecution, CoreMethodExecution,
CoreScanResult, CoreScanResult,
@ -16,6 +18,8 @@ __all__ = [
"CoreScanResult", "CoreScanResult",
"CoreAvailabilityResult", "CoreAvailabilityResult",
"CoreBusySlot", "CoreBusySlot",
"CoreMeetingInterval",
"CoreMeetingIntervalsResult",
"CoreUnsubscribeDigestResult", "CoreUnsubscribeDigestResult",
"CoreUnsubscribeMethod", "CoreUnsubscribeMethod",
"CoreMailingListCandidate", "CoreMailingListCandidate",

@ -29,6 +29,21 @@ class CoreAvailabilityResult:
checked_calendars: list[str] 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) @dataclass(frozen=True)
class CoreUnsubscribeDigestResult: class CoreUnsubscribeDigestResult:
scanned_messages: int scanned_messages: int

@ -17,6 +17,8 @@ from app.unsubscribe_hil_agent import (
from app.core.models import ( from app.core.models import (
CoreAvailabilityResult, CoreAvailabilityResult,
CoreBusySlot, CoreBusySlot,
CoreMeetingInterval,
CoreMeetingIntervalsResult,
CoreMailingListCandidate, CoreMailingListCandidate,
CoreMethodExecution, CoreMethodExecution,
CoreScanResult, CoreScanResult,
@ -72,6 +74,26 @@ class CoreAgentService:
checked_calendars=result.checked_calendars, 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: def scan_unsubscribe_digest(self, max_results: int) -> CoreUnsubscribeDigestResult:
bounded_max_results = max(1, min(max_results, 500)) bounded_max_results = max(1, min(max_results, 500))
gmail_service = build_gmail_service(self.settings) gmail_service = build_gmail_service(self.settings)

@ -55,6 +55,25 @@ class AvailabilityResponse(BaseModel):
checked_calendars: list[str] 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): class UnsubscribeDigestResponse(BaseModel):
scanned_messages: int scanned_messages: int
extracted_unique_links: 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: def _run_unsubscribe_candidates_once(max_results: int) -> UnsubscribeCandidatesResponse:
result = core_service.list_unsubscribe_candidates(max_results=max_results) result = core_service.list_unsubscribe_candidates(max_results=max_results)
return UnsubscribeCandidatesResponse( return UnsubscribeCandidatesResponse(
@ -418,6 +459,33 @@ async def availability(request: AvailabilityRequest) -> AvailabilityResponse:
) from exc ) 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( @app.post(
"/unsubscribe-digest", "/unsubscribe-digest",
response_model=UnsubscribeDigestResponse, response_model=UnsubscribeDigestResponse,

@ -10,7 +10,7 @@ from mcp.server.auth.settings import AuthSettings
from app.config import Settings from app.config import Settings
from app.security import AuthBackend 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: 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]: def mcp_supported_scopes(settings: Settings) -> list[str]:
scopes = [_MCP_BASE_SCOPE] scopes = list(_MCP_ALWAYS_ENABLED_SCOPES)
if settings.mcp_enable_mutation_tools: if settings.mcp_enable_mutation_tools:
scopes.extend(["mail:scan", "unsubscribe:read", "unsubscribe:execute"]) scopes.extend(["mail:scan", "unsubscribe:read", "unsubscribe:execute"])
return scopes return scopes
@ -34,7 +34,8 @@ def build_mcp_oauth_auth_settings(settings: Settings) -> AuthSettings:
return AuthSettings( return AuthSettings(
issuer_url=settings.mcp_oauth_issuer, issuer_url=settings.mcp_oauth_issuer,
resource_server_url=settings.mcp_resource_server_url, 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,
) )

@ -12,6 +12,7 @@ from app.mcp.oauth import (
resolve_mcp_auth_mode, resolve_mcp_auth_mode,
) )
from app.mcp.tools import ( from app.mcp.tools import (
available_meeting_intervals as available_meeting_intervals_impl,
check_availability as check_availability_impl, check_availability as check_availability_impl,
execute_unsubscribe as execute_unsubscribe_impl, execute_unsubscribe as execute_unsubscribe_impl,
list_unsubscribe_candidates as list_unsubscribe_candidates_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]: def scan_mailbox(max_results: int = 100, ctx: Context | None = None) -> dict[str, object]:
return scan_mailbox_impl(max_results=max_results, ctx=ctx) return scan_mailbox_impl(max_results=max_results, ctx=ctx)
@ -82,6 +97,13 @@ def _register_tools(server: FastMCP, settings: Settings) -> None:
check_availability, check_availability,
description="Check Google Calendar availability for a time range.", 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: if settings.mcp_enable_mutation_tools:
server.add_tool( server.add_tool(

@ -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]: 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.""" """Scan inbox emails and classify/move them according to current routing rules."""
_require_scope(ctx, "mail:scan") _require_scope(ctx, "mail:scan")

@ -22,7 +22,7 @@ When using Docker Compose:
Implemented methods: Implemented methods:
- `ping` / `health.ping` / `health/ping` - `ping` / `health.ping` / `health/ping`
- `SendMessage` (availability only) - `SendMessage` (availability + meeting intervals)
## Authentication ## Authentication
@ -34,7 +34,8 @@ The A2A adapter uses the same auth backend as REST:
Required scope for `SendMessage`: 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 ## Request shape for SendMessage
@ -52,6 +53,7 @@ Accepted locations:
Optional: Optional:
- `action`: `available_meeting_intervals` to request interval suggestions (omit for default availability check)
- `calendar_ids`: array of calendar ids (defaults to `["primary"]`) - `calendar_ids`: array of calendar ids (defaults to `["primary"]`)
## Smoke tests ## 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 ## Error mapping
- `-32600`: invalid JSON-RPC request - `-32600`: invalid JSON-RPC request
@ -93,7 +114,7 @@ curl -X POST "http://127.0.0.1:8000/a2a/rpc" \
If you get `-32001`: If you get `-32001`:
- Verify `AUTH_MODE` - 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`: If you get `-32602`:
@ -104,4 +125,3 @@ If you get `-32000` with OAuth file errors:
- Check `GOOGLE_CLIENT_SECRETS_FILE` path - Check `GOOGLE_CLIENT_SECRETS_FILE` path
- Check `GOOGLE_TOKEN_FILE` path - Check `GOOGLE_TOKEN_FILE` path

@ -29,6 +29,7 @@ Docker Compose service:
Always enabled: Always enabled:
- `check_availability` - `check_availability`
- `available_meeting_intervals`
Optional mutation tools (disabled by default): 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` - `/.well-known/oauth-protected-resource/mcp`
and returns OAuth-compliant `WWW-Authenticate: Bearer ...` challenges for unauthenticated requests. and returns OAuth-compliant `WWW-Authenticate: Bearer ...` challenges for unauthenticated requests.
Scope authorization is enforced at tool level (per required scope above).
Required scopes: Required scopes:
- `check_availability`: `availability:read` - `check_availability`: `availability:read`
- `available_meeting_intervals`: `available_meeting_intervals:read`
- `scan_mailbox`: `mail:scan` - `scan_mailbox`: `mail:scan`
- `list_unsubscribe_candidates`: `unsubscribe:read` - `list_unsubscribe_candidates`: `unsubscribe:read`
- `execute_unsubscribe`: `unsubscribe:execute` - `execute_unsubscribe`: `unsubscribe:execute`
@ -97,8 +100,8 @@ PY
Expected output by mode: Expected output by mode:
- default: `['check_availability']` - default: `['check_availability', 'available_meeting_intervals']`
- with `MCP_ENABLE_MUTATION_TOOLS=true`: all four tools - with `MCP_ENABLE_MUTATION_TOOLS=true`: all five tools
## Protocol notes ## Protocol notes

@ -37,6 +37,7 @@ Internal traffic:
## Scope matrix ## Scope matrix
- `availability:read`: availability access - `availability:read`: availability access
- `available_meeting_intervals:read`: suggested meeting intervals access
- `mail:scan`: inbox scan and triage - `mail:scan`: inbox scan and triage
- `unsubscribe:read`: candidate discovery - `unsubscribe:read`: candidate discovery
- `unsubscribe:execute`: unsubscribe execution - `unsubscribe:execute`: unsubscribe execution

@ -6,6 +6,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"apscheduler", "apscheduler",
"fastapi", "fastapi",
"holidays",
"mcp", "mcp",
"pytest", "pytest",
"google-api-python-client", "google-api-python-client",

@ -4,6 +4,7 @@ from dataclasses import replace
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import app.a2a.router as a2a_module import app.a2a.router as a2a_module
@ -27,6 +28,39 @@ class _DummyCoreService:
checked_calendars=checked, 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: def _build_test_app() -> FastAPI:
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["error"] is None
assert payload["result"]["availability"]["available"] is True assert payload["result"]["availability"]["available"] is True
assert payload["result"]["availability"]["checked_calendars"] == ["primary"] 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

@ -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,
)

@ -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",
)
]

@ -41,6 +41,25 @@ class _DummyCoreService:
checked_calendars=calendar_ids or ["primary"], 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: async def _noop_task() -> None:
return None return None
@ -91,3 +110,44 @@ def test_main_availability_endpoint_rejects_missing_key(monkeypatch) -> None:
) )
assert response.status_code == 401 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

@ -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

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from dataclasses import replace from dataclasses import replace
import pytest import pytest
@ -43,7 +44,7 @@ def test_mcp_oauth_exposes_protected_resource_metadata() -> None:
assert [value.rstrip("/") for value in payload["authorization_servers"]] == [ assert [value.rstrip("/") for value in payload["authorization_servers"]] == [
"https://issuer.example" "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: 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"): with pytest.raises(ValueError, match="MCP_RESOURCE_SERVER_URL"):
build_mcp_server(settings=settings, token_verifier=_StaticTokenVerifier()) 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

@ -35,6 +35,25 @@ class _DummyCoreService:
failed=0, 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: class _DummyCtx:
def __init__(self, headers: dict[str, str]) -> None: 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 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: def test_mcp_auth_mode_oauth_uses_bearer_token(monkeypatch) -> None:
mcp_settings = replace( mcp_settings = replace(
get_settings(), get_settings(),
@ -135,3 +199,38 @@ def test_mcp_auth_mode_oauth_uses_bearer_token(monkeypatch) -> None:
assert payload["available"] is True assert payload["available"] is True
assert payload["checked_calendars"] == ["primary"] 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"}),
)

@ -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",
}
]

@ -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" }, { 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]] [[package]]
name = "httpcore" name = "httpcore"
version = "1.0.9" version = "1.0.9"
@ -822,6 +834,7 @@ dependencies = [
{ name = "google-api-python-client" }, { name = "google-api-python-client" },
{ name = "google-auth" }, { name = "google-auth" },
{ name = "google-auth-oauthlib" }, { name = "google-auth-oauthlib" },
{ name = "holidays" },
{ name = "mcp" }, { name = "mcp" },
{ name = "pytest" }, { name = "pytest" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@ -836,6 +849,7 @@ requires-dist = [
{ name = "google-api-python-client" }, { name = "google-api-python-client" },
{ name = "google-auth" }, { name = "google-auth" },
{ name = "google-auth-oauthlib" }, { name = "google-auth-oauthlib" },
{ name = "holidays" },
{ name = "mcp" }, { name = "mcp" },
{ name = "pytest" }, { name = "pytest" },
{ name = "python-dotenv" }, { name = "python-dotenv" },

Loading…
Cancel
Save