[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:
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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)}"}
|
||||
@@ -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
Reference in New Issue
Block a user