[integration] feat(tools): add Google Calendar integration (#3171)

* feat(calendar): add Google Calendar integration with event management tools and health checks

* fix(calendar): align google_calendar_oauth credential spec with codebase pattern
This commit is contained in:
Aaryann Chandola
2026-02-15 05:55:53 +05:30
committed by GitHub
parent 5e4f322fc0
commit 0c7ea272db
11 changed files with 2572 additions and 2 deletions
+1 -1
View File
@@ -508,7 +508,7 @@ All credential specs are defined in `tools/src/aden_tools/credentials/`:
| `llm.py` | LLM Providers | `anthropic` | No |
| `search.py` | Search Tools | `brave_search`, `google_search`, `google_cse` | No |
| `email.py` | Email | `resend` | No |
| `integrations.py` | Integrations | `github`, `hubspot` | No / Yes |
| `integrations.py` | Integrations | `github`, `hubspot`, `google_calendar_oauth` | No / Yes |
**Note:** Additional LLM providers (Cerebras, Groq, OpenAI) are handled by LiteLLM via environment
variables (`CEREBRAS_API_KEY`, `GROQ_API_KEY`, `OPENAI_API_KEY`) but are not yet in CREDENTIAL_SPECS.
+10 -1
View File
@@ -76,6 +76,14 @@ python mcp_server.py
| `web_scrape` | Scrape and extract content from webpages |
| `pdf_read` | Read and extract text from PDF files |
| `get_current_time` | Get current date/time with timezone support |
| `calendar_list_calendars` | List all accessible calendars |
| `calendar_list_events` | List events from a calendar |
| `calendar_get_event` | Get details of a specific event |
| `calendar_create_event`| Create a new calendar event |
| `calendar_update_event`| Update an existing calendar event |
| `calendar_delete_event`| Delete a calendar event |
| `calendar_get_calendar`| Get calendar metadata |
| `calendar_check_availability` | Check free/busy status for attendees |
## Project Structure
@@ -98,7 +106,8 @@ tools/
│ ├── web_search_tool/
│ ├── web_scrape_tool/
│ ├── pdf_read_tool/
── time_tool/
── time_tool/
│ └── calendar_tool/
├── tests/ # Test suite
├── mcp_server.py # MCP server entry point
├── README.md
@@ -58,6 +58,7 @@ from .browser import get_aden_auth_url, get_aden_setup_url, open_browser
from .email import EMAIL_CREDENTIALS
from .gcp_vision import GCP_VISION_CREDENTIALS
from .github import GITHUB_CREDENTIALS
from .google_calendar import GOOGLE_CALENDAR_CREDENTIALS
from .google_maps import GOOGLE_MAPS_CREDENTIALS
from .health_check import HealthCheckResult, check_credential_health
from .hubspot import HUBSPOT_CREDENTIALS
@@ -86,6 +87,7 @@ CREDENTIAL_SPECS = {
**GITHUB_CREDENTIALS,
**GOOGLE_MAPS_CREDENTIALS,
**HUBSPOT_CREDENTIALS,
**GOOGLE_CALENDAR_CREDENTIALS,
**SLACK_CREDENTIALS,
**SERPAPI_CREDENTIALS,
**TELEGRAM_CREDENTIALS,
@@ -122,6 +124,7 @@ __all__ = [
"GITHUB_CREDENTIALS",
"GOOGLE_MAPS_CREDENTIALS",
"HUBSPOT_CREDENTIALS",
"GOOGLE_CALENDAR_CREDENTIALS",
"SLACK_CREDENTIALS",
"APOLLO_CREDENTIALS",
"SERPAPI_CREDENTIALS",
@@ -0,0 +1,39 @@
"""
Google Calendar tool credentials.
Contains credentials for Google Calendar integration.
"""
from .base import CredentialSpec
GOOGLE_CALENDAR_CREDENTIALS = {
"google_calendar_oauth": CredentialSpec(
env_var="GOOGLE_CALENDAR_ACCESS_TOKEN",
tools=[
"calendar_list_events",
"calendar_get_event",
"calendar_create_event",
"calendar_update_event",
"calendar_delete_event",
"calendar_list_calendars",
"calendar_get_calendar",
"calendar_check_availability",
],
node_types=[],
required=False,
startup_required=False,
help_url="https://hive.adenhq.com",
description="Google Calendar OAuth2 access token (via Aden) - used for Google Calendar",
# Auth method support
aden_supported=True,
aden_provider_name="google-calendar",
direct_api_key_supported=False,
api_key_instructions="Google Calendar OAuth requires OAuth2. Connect via hive.adenhq.com",
# Health check configuration
health_check_endpoint="https://www.googleapis.com/calendar/v3/users/me/calendarList",
health_check_method="GET",
# Credential store mapping
credential_id="google_calendar_oauth",
credential_key="access_token",
),
}
@@ -162,6 +162,69 @@ class BraveSearchHealthChecker:
)
class GoogleCalendarHealthChecker:
"""Health checker for Google Calendar OAuth tokens."""
ENDPOINT = "https://www.googleapis.com/calendar/v3/users/me/calendarList"
TIMEOUT = 10.0
def check(self, access_token: str) -> HealthCheckResult:
"""
Validate Google Calendar token by making lightweight API call.
Makes a GET request for 1 calendar to verify the token works.
"""
try:
with httpx.Client(timeout=self.TIMEOUT) as client:
response = client.get(
self.ENDPOINT,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
params={"maxResults": "1"},
)
if response.status_code == 200:
return HealthCheckResult(
valid=True,
message="Google Calendar credentials valid",
)
elif response.status_code == 401:
return HealthCheckResult(
valid=False,
message="Google Calendar token is invalid or expired",
details={"status_code": 401},
)
elif response.status_code == 403:
return HealthCheckResult(
valid=False,
message="Google Calendar token lacks required scopes",
details={"status_code": 403, "required": "calendar"},
)
else:
return HealthCheckResult(
valid=False,
message=f"Google Calendar API returned status {response.status_code}",
details={"status_code": response.status_code},
)
except httpx.TimeoutException:
return HealthCheckResult(
valid=False,
message="Google Calendar API request timed out",
details={"error": "timeout"},
)
except httpx.RequestError as e:
error_msg = str(e)
if "Bearer" in error_msg or "Authorization" in error_msg:
error_msg = "Request failed (details redacted for security)"
return HealthCheckResult(
valid=False,
message=f"Failed to connect to Google Calendar: {error_msg}",
details={"error": error_msg},
)
class GoogleSearchHealthChecker:
"""Health checker for Google Custom Search API."""
@@ -563,6 +626,7 @@ class GoogleMapsHealthChecker:
HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
"hubspot": HubSpotHealthChecker(),
"brave_search": BraveSearchHealthChecker(),
"google_calendar_oauth": GoogleCalendarHealthChecker(),
"slack": SlackHealthChecker(),
"google_search": GoogleSearchHealthChecker(),
"google_maps": GoogleMapsHealthChecker(),
+10
View File
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
# Import register_tools from each tool module
from .apollo_tool import register_tools as register_apollo
from .bigquery_tool import register_tools as register_bigquery
from .calendar_tool import register_tools as register_calendar
from .csv_tool import register_tools as register_csv
from .email_tool import register_tools as register_email
from .example_tool import register_tools as register_example
@@ -92,6 +93,7 @@ def register_all_tools(
register_news(mcp, credentials=credentials)
register_apollo(mcp, credentials=credentials)
register_serpapi(mcp, credentials=credentials)
register_calendar(mcp, credentials=credentials)
register_slack(mcp, credentials=credentials)
register_telegram(mcp, credentials=credentials)
register_vision(mcp, credentials=credentials)
@@ -195,6 +197,14 @@ def register_all_tools(
"scholar_get_author",
"patents_search",
"patents_get_details",
"calendar_list_events",
"calendar_get_event",
"calendar_create_event",
"calendar_update_event",
"calendar_delete_event",
"calendar_list_calendars",
"calendar_get_calendar",
"calendar_check_availability",
"slack_send_message",
"slack_list_channels",
"slack_get_channel_history",
@@ -0,0 +1,265 @@
# Google Calendar Tool
A tool for managing Google Calendar events, checking availability, and coordinating schedules.
## Features
- **Events**: Create, read, update, and delete calendar events
- **Calendars**: List and access user's calendars
- **Availability**: Check free/busy times for smart scheduling
- **Attendees**: Add participants and send meeting invites
## Setup
### Option A: Aden OAuth (Recommended)
Use Aden's managed OAuth flow for automatic token refresh:
1. Set `aden_provider_name="google-calendar"` in your agent's credential spec
2. Aden handles the OAuth flow and token refresh automatically
### Option B: Direct Token (Testing)
For quick testing, get a token from the [Google OAuth Playground](https://developers.google.com/oauthplayground/):
1. Go to OAuth Playground
2. Select "Google Calendar API v3" scopes
3. Authorize and get an access token
4. Set the environment variable:
```bash
export GOOGLE_CALENDAR_ACCESS_TOKEN="your-access-token"
```
**Note:** Access tokens from OAuth Playground expire after ~1 hour. For production, use Aden OAuth.
## Authentication
This tool uses OAuth 2.0 for authentication with Google Calendar API.
**Default scope:**
- `https://www.googleapis.com/auth/calendar` - Full read/write access to calendars and events
**Alternative (read-only):**
- `https://www.googleapis.com/auth/calendar.readonly` - Read-only access
## Tools
### calendar_list_events
List upcoming calendar events.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| calendar_id | str | No | "primary" | Calendar ID or "primary" for main calendar |
| time_min | str | No | now | Start time (ISO 8601 format) |
| time_max | str | No | None | End time (ISO 8601 format) |
| max_results | int | No | 10 | Maximum events to return (1-2500) |
| query | str | No | None | Free text search terms |
**Example:**
```python
calendar_list_events(
calendar_id="primary",
time_min="2024-01-15T00:00:00Z",
time_max="2024-01-22T00:00:00Z",
max_results=20
)
```
### calendar_get_event
Get details of a specific event.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| event_id | str | Yes | - | The event ID |
| calendar_id | str | No | "primary" | Calendar ID |
### calendar_create_event
Create a new calendar event.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| summary | str | Yes | - | Event title |
| start_time | str | Yes | - | Start time (ISO 8601). For all-day events: "YYYY-MM-DD" |
| end_time | str | Yes | - | End time (ISO 8601). For all-day events: "YYYY-MM-DD" (exclusive) |
| calendar_id | str | No | "primary" | Calendar ID |
| description | str | No | None | Event description |
| location | str | No | None | Event location |
| attendees | list[str] | No | None | List of attendee emails |
| send_notifications | bool | No | True | Send invite emails to attendees |
| timezone | str | No | None | IANA timezone (e.g., "America/New_York"). Ignored for all-day events. |
| all_day | bool | No | False | Create an all-day event (uses date-only start/end) |
**Note:** When attendees are provided, a Google Meet link is automatically generated.
**Example (timed event):**
```python
calendar_create_event(
summary="Team Standup",
start_time="2024-01-15T09:00:00",
end_time="2024-01-15T09:30:00",
timezone="America/New_York",
attendees=["alice@example.com", "bob@example.com"],
description="Daily sync meeting"
)
```
**Example (all-day event):**
```python
calendar_create_event(
summary="Company Holiday",
start_time="2024-12-25",
end_time="2024-12-26", # end date is exclusive
all_day=True
)
```
### calendar_update_event
Update an existing event. Only provided fields are changed (uses PATCH).
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| event_id | str | Yes | - | The event ID to update |
| calendar_id | str | No | "primary" | Calendar ID |
| summary | str | No | None | New event title |
| start_time | str | No | None | New start time. For all-day: "YYYY-MM-DD" |
| end_time | str | No | None | New end time. For all-day: "YYYY-MM-DD" |
| description | str | No | None | New description |
| location | str | No | None | New location |
| attendees | list[str] | No | None | Updated attendee list |
| send_notifications | bool | No | True | Send update emails |
| timezone | str | No | None | IANA timezone (e.g., "America/New_York"). Ignored for all-day. |
| all_day | bool | No | False | Convert to all-day event (requires start_time + end_time) |
| add_meet_link | bool | No | False | Add a Google Meet link to the event |
### calendar_delete_event
Delete a calendar event.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| event_id | str | Yes | - | The event ID to delete |
| calendar_id | str | No | "primary" | Calendar ID |
| send_notifications | bool | No | True | Send cancellation emails |
### calendar_list_calendars
List all calendars accessible to the user.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| max_results | int | No | 100 | Maximum calendars to return |
### calendar_get_calendar
Get details of a specific calendar.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| calendar_id | str | Yes | - | The calendar ID |
### calendar_check_availability
Check free/busy status for scheduling.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| time_min | str | Yes | - | Start of time range (ISO 8601) |
| time_max | str | Yes | - | End of time range (ISO 8601) |
| calendars | list[str] | No | ["primary"] | Calendar IDs to check |
| timezone | str | No | "UTC" | Timezone for the query |
**Example:**
```python
calendar_check_availability(
time_min="2024-01-15T00:00:00Z",
time_max="2024-01-16T00:00:00Z",
calendars=["primary", "team-calendar@group.calendar.google.com"]
)
```
**Response:**
```json
{
"time_min": "2024-01-15T00:00:00Z",
"time_max": "2024-01-16T00:00:00Z",
"calendars": {
"primary": {
"busy": [
{"start": "2024-01-15T09:00:00Z", "end": "2024-01-15T10:00:00Z"},
{"start": "2024-01-15T14:00:00Z", "end": "2024-01-15T15:00:00Z"}
]
}
}
}
```
## Error Handling
All tools return a dict with either success data or an error:
**Success:**
```json
{
"id": "event123",
"summary": "Team Meeting",
"start": {"dateTime": "2024-01-15T09:00:00Z"},
"end": {"dateTime": "2024-01-15T10:00:00Z"}
}
```
**Error:**
```json
{
"error": "Calendar credentials not configured",
"help": "Set GOOGLE_CALENDAR_ACCESS_TOKEN environment variable"
}
```
## Common Use Cases
### Schedule a meeting with availability check
```python
# 1. Check when everyone is free
availability = calendar_check_availability(
time_min="2024-01-15T00:00:00Z",
time_max="2024-01-19T00:00:00Z"
)
# 2. Create the meeting at a free slot
event = calendar_create_event(
summary="Project Review",
start_time="2024-01-16T14:00:00Z",
end_time="2024-01-16T15:00:00Z",
attendees=["team@example.com"]
)
```
### Get today's agenda
```python
from datetime import datetime, timedelta
today = datetime.now().replace(hour=0, minute=0, second=0)
tomorrow = today + timedelta(days=1)
events = calendar_list_events(
time_min=today.isoformat() + "Z",
time_max=tomorrow.isoformat() + "Z"
)
```
## API Reference
This tool uses the [Google Calendar API v3](https://developers.google.com/calendar/api/v3/reference).
@@ -0,0 +1,5 @@
"""Google Calendar Tool package."""
from .calendar_tool import register_tools
__all__ = ["register_tools"]
@@ -0,0 +1,810 @@
"""
Google Calendar Tool - Manage calendar events and check availability.
Supports:
- Event CRUD operations (list, get, create, update, delete)
- Calendar listing and details
- Free/busy availability checks
Requires OAuth 2.0 credentials:
- Aden: Use aden_provider_name="google-calendar" for managed OAuth (recommended)
- Direct: Set GOOGLE_CALENDAR_ACCESS_TOKEN with token from OAuth Playground
"""
from __future__ import annotations
import logging
import os
import re
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from urllib.parse import quote
from zoneinfo import available_timezones
import httpx
from fastmcp import FastMCP
if TYPE_CHECKING:
from framework.credentials.oauth2 import TokenLifecycleManager
from aden_tools.credentials import CredentialStoreAdapter
logger = logging.getLogger(__name__)
# Google Calendar API base URL
CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"
def _create_lifecycle_manager(
credentials: CredentialStoreAdapter,
) -> TokenLifecycleManager | None:
"""
Create a TokenLifecycleManager for automatic token refresh.
Currently returns None because token refresh is handled server-side by Aden's
OAuth infrastructure. When using Aden OAuth, tokens are refreshed automatically
before they expire. For direct API access (testing), use a short-lived token
from the OAuth Playground - these tokens expire after ~1 hour.
This function exists as a hook for future local token refresh if needed.
"""
return None
def register_tools(
mcp: FastMCP,
credentials: CredentialStoreAdapter | None = None,
) -> None:
"""Register Google Calendar tools with the MCP server."""
# Create lifecycle manager for auto-refresh (if possible)
lifecycle_manager: TokenLifecycleManager | None = None
if credentials is not None:
lifecycle_manager = _create_lifecycle_manager(credentials)
if lifecycle_manager:
logger.info("Google Calendar OAuth auto-refresh enabled")
def _get_token() -> str | None:
"""
Get OAuth token, refreshing if needed.
Priority:
1. TokenLifecycleManager (auto-refresh) if available
2. CredentialStoreAdapter (includes env var fallback)
3. Environment variable (direct fallback if no adapter)
"""
# Try lifecycle manager first (handles auto-refresh)
if lifecycle_manager is not None:
token = lifecycle_manager.sync_get_valid_token()
if token is not None:
return token.access_token
# Fall back to credential store adapter
if credentials is not None:
return credentials.get("google_calendar_oauth")
# Fall back to environment variable
return os.getenv("GOOGLE_CALENDAR_ACCESS_TOKEN")
def _get_headers() -> dict[str, str]:
"""Get authorization headers for API requests.
Note: Callers must use _check_credentials() first to ensure token exists.
"""
token = _get_token()
if token is None:
token = "" # Will fail auth but prevents "Bearer None" in logs
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def _check_credentials() -> dict | None:
"""Check if credentials are configured. Returns error dict if not."""
token = _get_token()
if not token:
return {
"error": "Calendar credentials not configured",
"help": "Set GOOGLE_CALENDAR_ACCESS_TOKEN environment variable",
}
return None
def _encode_id(id_value: str) -> str:
"""URL-encode a calendar or event ID for safe use in URLs."""
return quote(id_value, safe="")
def _sanitize_error(e: Exception) -> str:
"""Sanitize exception message to avoid leaking sensitive data like tokens."""
msg = str(e)
# httpx.RequestError can include headers with Bearer token
# Only return the error type and a safe portion of the message
if "Bearer" in msg or "Authorization" in msg:
return f"{type(e).__name__}: Request failed (details redacted for security)"
# Truncate long messages that might contain sensitive data
if len(msg) > 200:
return f"{type(e).__name__}: {msg[:200]}..."
return msg
# Pre-compute valid timezones once
_VALID_TIMEZONES = available_timezones()
# Pattern for date-only strings (YYYY-MM-DD)
_DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _validate_timezone(tz: str) -> dict | None:
"""Validate a timezone string. Returns error dict if invalid, None if valid."""
if tz not in _VALID_TIMEZONES:
return {"error": f"Invalid timezone '{tz}'. Use IANA format (e.g., 'America/New_York')"}
return None
def _handle_response(response: httpx.Response) -> dict:
"""Handle API response and return appropriate result."""
if response.status_code == 401:
# If we have a lifecycle manager, the token should have auto-refreshed
# If we still get 401, the refresh token is likely invalid
if lifecycle_manager is not None:
return {
"error": "OAuth token expired and refresh failed",
"help": "Re-authenticate via Aden or get a new token from OAuth Playground",
}
return {
"error": "Invalid or expired OAuth token",
"help": "Get a new token from https://developers.google.com/oauthplayground/",
}
elif response.status_code == 403:
return {
"error": "Access denied. Check calendar permissions.",
"help": "Ensure the OAuth token has calendar.events scope",
}
elif response.status_code == 404:
return {"error": "Resource not found"}
elif response.status_code == 429:
return {"error": "Rate limit exceeded. Try again later."}
elif response.status_code >= 400:
try:
error_data = response.json()
message = error_data.get("error", {}).get("message", "Unknown error")
return {"error": f"API error: {message}"}
except Exception:
return {"error": f"API request failed: HTTP {response.status_code}"}
return response.json()
@mcp.tool()
def calendar_list_events(
calendar_id: str = "primary",
time_min: str | None = None,
time_max: str | None = None,
max_results: int = 10,
query: str | None = None,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
List upcoming calendar events.
Args:
calendar_id: Calendar ID or "primary" for main calendar
time_min: Start time filter (ISO 8601 format, e.g., "2024-01-15T00:00:00Z")
time_max: End time filter (ISO 8601 format)
max_results: Maximum events to return (1-2500, default 10)
query: Free text search terms to filter events
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with list of events or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if max_results < 1 or max_results > 2500:
return {"error": "max_results must be between 1 and 2500"}
# Default time_min to now if not provided
if time_min is None:
time_min = datetime.now(UTC).isoformat()
params: dict = {
"maxResults": max_results,
"singleEvents": "true",
"orderBy": "startTime",
"timeMin": time_min,
}
if time_max:
params["timeMax"] = time_max
if query:
params["q"] = query
try:
response = httpx.get(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}/events",
headers=_get_headers(),
params=params,
timeout=30.0,
)
result = _handle_response(response)
if "error" in result:
return result
# Format events for cleaner output
events = []
for item in result.get("items", []):
start = item.get("start", {})
end = item.get("end", {})
event_data = {
"id": item.get("id"),
"summary": item.get("summary", "(No title)"),
"start": start.get("dateTime") or start.get("date"),
"end": end.get("dateTime") or end.get("date"),
"location": item.get("location"),
"status": item.get("status"),
"html_link": item.get("htmlLink"),
"description": item.get("description"),
"hangoutLink": item.get("hangoutLink"),
}
if item.get("attendees"):
event_data["attendees"] = [a.get("email") for a in item["attendees"]]
events.append(event_data)
return {
"calendar_id": calendar_id,
"events": events,
"total": len(events),
}
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_get_event(
event_id: str,
calendar_id: str = "primary",
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
Get details of a specific calendar event.
Args:
event_id: The event ID to retrieve
calendar_id: Calendar ID or "primary" for main calendar
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with event details or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if not event_id:
return {"error": "event_id is required"}
try:
response = httpx.get(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}/events/{_encode_id(event_id)}",
headers=_get_headers(),
timeout=30.0,
)
return _handle_response(response)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_create_event(
summary: str,
start_time: str,
end_time: str,
calendar_id: str = "primary",
description: str | None = None,
location: str | None = None,
attendees: list[str] | None = None,
send_notifications: bool = True,
timezone: str | None = None,
all_day: bool = False,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
Create a new calendar event.
Args:
summary: Event title
start_time: Start time (ISO 8601 format, e.g., "2024-01-15T09:00:00").
For all-day events use date-only format: "2024-01-15"
end_time: End time (ISO 8601 format).
For all-day events use date-only format: "2024-01-16"
(end date is exclusive a 1-day event on Jan 15 uses end "2024-01-16")
calendar_id: Calendar ID or "primary" for main calendar
description: Event description/notes
location: Event location (address or room name)
attendees: List of attendee email addresses
send_notifications: Whether to send email invites to attendees
timezone: Timezone for the event (e.g., "America/New_York"). Ignored for all-day events.
all_day: If True, creates an all-day event using date-only start/end
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with created event details or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if not summary:
return {"error": "summary is required"}
if not start_time:
return {"error": "start_time is required"}
if not end_time:
return {"error": "end_time is required"}
# Validate timezone if provided
if timezone and not all_day:
tz_error = _validate_timezone(timezone)
if tz_error:
return tz_error
# Build event body
if all_day:
# Validate date-only format for all-day events
if not _DATE_ONLY_RE.match(start_time):
return {
"error": "all-day events require date-only format for start_time (YYYY-MM-DD)"
}
if not _DATE_ONLY_RE.match(end_time):
return {
"error": "all-day events require date-only format for end_time (YYYY-MM-DD)"
}
event_body: dict = {
"summary": summary,
"start": {"date": start_time},
"end": {"date": end_time},
}
else:
event_body = {
"summary": summary,
"start": {"dateTime": start_time},
"end": {"dateTime": end_time},
}
if timezone:
event_body["start"]["timeZone"] = timezone
event_body["end"]["timeZone"] = timezone
if description is not None:
event_body["description"] = description
if location is not None:
event_body["location"] = location
if attendees:
event_body["attendees"] = [{"email": email} for email in attendees]
# Auto-generate Google Meet link when attendees are present
event_body["conferenceData"] = {
"createRequest": {
"requestId": f"meet-{uuid.uuid4().hex[:12]}",
"conferenceSolutionKey": {"type": "hangoutsMeet"},
}
}
params: dict = {"sendUpdates": "all" if send_notifications else "none"}
# Enable conference data support for Meet link generation
if attendees:
params["conferenceDataVersion"] = 1
try:
response = httpx.post(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}/events",
headers=_get_headers(),
json=event_body,
params=params,
timeout=30.0,
)
return _handle_response(response)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_update_event(
event_id: str,
calendar_id: str = "primary",
summary: str | None = None,
start_time: str | None = None,
end_time: str | None = None,
description: str | None = None,
location: str | None = None,
attendees: list[str] | None = None,
remove_attendees: list[str] | None = None,
send_notifications: bool = True,
timezone: str | None = None,
all_day: bool = False,
add_meet_link: bool = False,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
Update an existing calendar event. Only provided fields are changed.
Args:
event_id: The event ID to update
calendar_id: Calendar ID or "primary" for main calendar
summary: New event title (None to keep existing)
start_time: New start time (ISO 8601 format).
For all-day events use date-only format: "2024-01-15"
end_time: New end time (ISO 8601 format).
For all-day events use date-only format: "2024-01-16"
description: New description
location: New location
attendees: Updated list of attendee emails (replaces existing)
remove_attendees: List of attendee emails to remove from the event
send_notifications: Whether to send update emails
timezone: Timezone for the event (e.g., "America/New_York"). Ignored for all-day events.
all_day: If True and start_time/end_time are provided, converts to all-day event
add_meet_link: If True, adds a Google Meet link to the event
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with updated event details or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if not event_id:
return {"error": "event_id is required"}
# Validate timezone if provided
if timezone and not all_day:
tz_error = _validate_timezone(timezone)
if tz_error:
return tz_error
# Build partial body with only provided fields (PATCH semantics)
patch_body: dict = {}
if summary is not None:
patch_body["summary"] = summary
if description is not None:
patch_body["description"] = description
if location is not None:
patch_body["location"] = location
if remove_attendees is not None:
# Fetch current event to get attendee list
try:
get_response = httpx.get(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}/events/{_encode_id(event_id)}",
headers=_get_headers(),
timeout=30.0,
)
event_data = _handle_response(get_response)
if "error" in event_data:
return event_data
except httpx.TimeoutException:
return {"error": "Request timed out while fetching event"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
current_attendees = event_data.get("attendees", [])
remove_set = {e.lower() for e in remove_attendees}
remaining = [
a for a in current_attendees if a.get("email", "").lower() not in remove_set
]
patch_body["attendees"] = remaining
elif attendees is not None:
patch_body["attendees"] = [{"email": email} for email in attendees]
if add_meet_link:
patch_body["conferenceData"] = {
"createRequest": {
"requestId": f"meet-{uuid.uuid4().hex[:12]}",
"conferenceSolutionKey": {"type": "hangoutsMeet"},
}
}
if start_time is not None:
if all_day:
if not _DATE_ONLY_RE.match(start_time):
return {
"error": (
"all-day events require date-only format for start_time (YYYY-MM-DD)"
)
}
patch_body["start"] = {"date": start_time}
else:
patch_body["start"] = {"dateTime": start_time}
if timezone:
patch_body["start"]["timeZone"] = timezone
if end_time is not None:
if all_day:
if not _DATE_ONLY_RE.match(end_time):
return {
"error": (
"all-day events require date-only format for end_time (YYYY-MM-DD)"
)
}
patch_body["end"] = {"date": end_time}
else:
patch_body["end"] = {"dateTime": end_time}
if timezone:
patch_body["end"]["timeZone"] = timezone
if not patch_body:
return {"error": "No fields to update. Provide at least one field to change."}
params: dict = {"sendUpdates": "all" if send_notifications else "none"}
# Enable conference data support only when modifying conference data
if add_meet_link or attendees is not None or remove_attendees is not None:
params["conferenceDataVersion"] = 1
try:
response = httpx.patch(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}/events/{_encode_id(event_id)}",
headers=_get_headers(),
json=patch_body,
params=params,
timeout=30.0,
)
return _handle_response(response)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_delete_event(
event_id: str,
calendar_id: str = "primary",
send_notifications: bool = True,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
Delete a calendar event.
Args:
event_id: The event ID to delete
calendar_id: Calendar ID or "primary" for main calendar
send_notifications: Whether to send cancellation emails to attendees
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with success status or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if not event_id:
return {"error": "event_id is required"}
params = {"sendUpdates": "all" if send_notifications else "none"}
try:
response = httpx.delete(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}/events/{_encode_id(event_id)}",
headers=_get_headers(),
params=params,
timeout=30.0,
)
if response.status_code == 204:
return {"success": True, "message": f"Event {event_id} deleted"}
return _handle_response(response)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_list_calendars(
max_results: int = 100,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
List all calendars accessible to the user.
Args:
max_results: Maximum number of calendars to return (1-250)
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with list of calendars or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if max_results < 1 or max_results > 250:
return {"error": "max_results must be between 1 and 250"}
try:
response = httpx.get(
f"{CALENDAR_API_BASE}/users/me/calendarList",
headers=_get_headers(),
params={"maxResults": max_results},
timeout=30.0,
)
result = _handle_response(response)
if "error" in result:
return result
calendars = []
for item in result.get("items", []):
calendars.append(
{
"id": item.get("id"),
"summary": item.get("summary"),
"description": item.get("description"),
"primary": item.get("primary", False),
"access_role": item.get("accessRole"),
"background_color": item.get("backgroundColor"),
}
)
return {
"calendars": calendars,
"total": len(calendars),
}
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_get_calendar(
calendar_id: str,
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
Get details of a specific calendar.
Args:
calendar_id: The calendar ID to retrieve
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with calendar details or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if not calendar_id:
return {"error": "calendar_id is required"}
try:
response = httpx.get(
f"{CALENDAR_API_BASE}/calendars/{_encode_id(calendar_id)}",
headers=_get_headers(),
timeout=30.0,
)
return _handle_response(response)
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
@mcp.tool()
def calendar_check_availability(
time_min: str,
time_max: str,
calendars: list[str] | None = None,
timezone: str = "UTC",
# Tracking parameters (injected by framework, ignored by tool)
workspace_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
) -> dict:
"""
Check free/busy availability for scheduling.
Args:
time_min: Start of time range (ISO 8601 format)
time_max: End of time range (ISO 8601 format)
calendars: List of calendar IDs to check (defaults to ["primary"])
timezone: Timezone for the query (e.g., "America/New_York")
workspace_id: Tracking parameter (injected by framework)
agent_id: Tracking parameter (injected by framework)
session_id: Tracking parameter (injected by framework)
Returns:
Dict with busy periods for each calendar or error message
"""
cred_error = _check_credentials()
if cred_error:
return cred_error
if not time_min:
return {"error": "time_min is required"}
if not time_max:
return {"error": "time_max is required"}
if calendars is None:
calendars = ["primary"]
request_body = {
"timeMin": time_min,
"timeMax": time_max,
"timeZone": timezone,
"items": [{"id": cal_id} for cal_id in calendars],
}
try:
response = httpx.post(
f"{CALENDAR_API_BASE}/freeBusy",
headers=_get_headers(),
json=request_body,
timeout=30.0,
)
result = _handle_response(response)
if "error" in result:
return result
# Format the response for easier consumption
formatted_calendars = {}
for cal_id, cal_data in result.get("calendars", {}).items():
if "errors" in cal_data:
formatted_calendars[cal_id] = {
"error": cal_data["errors"][0].get("reason", "Unknown error")
}
else:
formatted_calendars[cal_id] = {"busy": cal_data.get("busy", [])}
return {
"time_min": time_min,
"time_max": time_max,
"timezone": timezone,
"calendars": formatted_calendars,
}
except httpx.TimeoutException:
return {"error": "Request timed out"}
except httpx.RequestError as e:
return {"error": f"Network error: {_sanitize_error(e)}"}
+63
View File
@@ -8,6 +8,7 @@ from aden_tools.credentials.health_check import (
HEALTH_CHECKERS,
AnthropicHealthChecker,
GitHubHealthChecker,
GoogleCalendarHealthChecker,
GoogleMapsHealthChecker,
GoogleSearchHealthChecker,
ResendHealthChecker,
@@ -43,6 +44,11 @@ class TestHealthCheckerRegistry:
assert "google_maps" in HEALTH_CHECKERS
assert isinstance(HEALTH_CHECKERS["google_maps"], GoogleMapsHealthChecker)
def test_google_calendar_oauth_registered(self):
"""GoogleCalendarHealthChecker is registered in HEALTH_CHECKERS."""
assert "google_calendar_oauth" in HEALTH_CHECKERS
assert isinstance(HEALTH_CHECKERS["google_calendar_oauth"], GoogleCalendarHealthChecker)
def test_all_expected_checkers_registered(self):
"""All expected health checkers are in the registry."""
expected = {
@@ -53,6 +59,7 @@ class TestHealthCheckerRegistry:
"anthropic",
"github",
"resend",
"google_calendar_oauth",
"slack",
}
assert set(HEALTH_CHECKERS.keys()) == expected
@@ -414,3 +421,59 @@ class TestCheckCredentialHealthDispatcher:
assert result.valid is True
assert result.details.get("partial_check") is True
class TestGoogleCalendarHealthCheckerTokenSanitization:
"""Tests for token sanitization in GoogleCalendarHealthChecker error handling."""
def test_request_error_with_bearer_token_sanitized(self):
"""GoogleCalendarHealthChecker sanitizes Bearer tokens in error messages."""
checker = GoogleCalendarHealthChecker()
with patch("aden_tools.credentials.health_check.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = httpx.RequestError(
"Connection failed with Bearer ya29.secret-token-here"
)
result = checker.check("ya29.secret-token-here")
assert not result.valid
assert "Bearer" not in result.message
assert "ya29" not in result.message
assert "redacted" in result.message
def test_request_error_with_authorization_header_sanitized(self):
"""GoogleCalendarHealthChecker sanitizes Authorization headers in errors."""
checker = GoogleCalendarHealthChecker()
with patch("aden_tools.credentials.health_check.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = httpx.RequestError(
"Failed sending Authorization: Bearer token123"
)
result = checker.check("token123")
assert not result.valid
assert "token123" not in result.message
assert "redacted" in result.message
def test_request_error_without_sensitive_data_passes_through(self):
"""Non-sensitive error messages pass through unchanged."""
checker = GoogleCalendarHealthChecker()
with patch("aden_tools.credentials.health_check.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = httpx.RequestError("Connection refused")
result = checker.check("token123")
assert not result.valid
assert "Connection refused" in result.message
File diff suppressed because it is too large Load Diff