Add available meeting intervals across MCP, REST, and A2A

master
oabrivard 7 days 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.
### 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`

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

@ -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."
)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save