Skip to content

Design: Outlook Calendar#

Overview#

Purpose: Expose Microsoft 365 calendar capabilities to AI assistants via the MCP protocol, enabling users to list events in a date range, retrieve event details, and list their calendars using delegated read-only permissions.

Scope: - Three MCP tools: list_events, get_event, list_calendars - Read-only access to the authenticated user's calendars via Microsoft Graph API - Timezone-aware event retrieval with automatic fallback to the user's mailbox timezone - OAuth2 On-Behalf-Of (OBO) authentication — see DESIGN:OAuthAuthentication

Out of Scope: - Event creation, modification, or deletion - Accepting or declining meeting invitations - Shared or delegate mailbox calendar access - Free/busy availability queries for other users - Meeting room or resource booking


Architecture#

┌────────────────────┐     ┌──────────────────────┐     ┌──────────────────────┐
│  AI Assistant      │────▶│  Outlook MCP Server  │────▶│  Microsoft Graph API │
│  (Claude / MCP     │     │  (FastMCP / ECS)     │     │  /v1.0/me/events     │
│   client)          │◀────│                      │◀────│  /v1.0/me/calendars  │
└────────────────────┘     └──────────────────────┘     └──────────────────────┘
                            ┌──────────────────────┐
                            │  OBOAuthenticator    │
                            │  (auth.py)           │
                            └──────────────────────┘
                            ┌──────────────────────┐
                            │  Token Storage       │
                            │  (DynamoDB / Memory) │
                            └──────────────────────┘

Components: - FastMCP Server (server.py) — ASGI HTTP server, MCP protocol handler, tool registration - Calendar Tools (tools/calendar.py) — MCP tool wrappers; timezone resolution logic - Graph Calendar Client (graph/calendar.py) — Microsoft Graph API calls for calendar resources - Graph Mail Client (graph/mail.py) — Used for mailbox settings (timezone fallback) - Graph Client (graph/client.py) — Authenticated HTTP client (OBO token injection) - OBOAuthenticator (auth.py) — On-Behalf-Of token exchange; see DESIGN:OAuthAuthentication - Microsoft Graph API — Upstream data source; enforces user-scoped authorization


Tech Stack#

Backend: Python 3.13, FastMCP ≥3.0.0, Starlette (ASGI) HTTP Client: httpx (async) OAuth: MSAL 1.28+, OBO flow via auth.py Token Storage: py-key-value-aio (DynamoDB in production, memory in dev) Infrastructure: AWS ECS Fargate, Application Load Balancer, ECR Secret Management: AWS SSM Parameter Store (KMS encrypted) Deployment: Docker (multi-stage), GitHub Actions CI/CD


MCP Tool API#

list_events#

Lists calendar events for the authenticated user within a date range.

Parameters: - start_date (str, required) — ISO 8601 datetime (e.g., 2026-04-22T00:00:00Z) - end_date (str, required) — ISO 8601 datetime (e.g., 2026-04-22T23:59:59Z) - count (int, default: 10) — maximum results to return - timezone (str, optional) — IANA timezone name (e.g., Europe/Amsterdam); falls back to user's mailbox timezone

Returns: List of { id, subject, start, end, location, isAllDay, organizer }

Graph endpoint: GET /v1.0/me/calendarView with startDateTime and endDateTime query parameters

Timezone handling: If timezone is not provided, _resolve_timezone() fetches the user's mailbox timezone via GET /v1.0/me/mailboxSettings. Falls back to UTC if mailbox settings are unavailable. The resolved timezone is passed to Graph via the Prefer: outlook.timezone header, so all returned datetimes are in the user's local timezone.


get_event#

Retrieves full details of a single calendar event by ID.

Parameters: - event_id (str, required) — unique Graph event ID - timezone (str, optional) — IANA timezone name; same fallback as list_events

Returns: { id, subject, start, end, location, body, attendees, organizer, isAllDay, recurrence, onlineMeeting, ... }

Graph endpoint: GET /v1.0/me/events/{id}


list_calendars#

Lists all calendars accessible to the authenticated user.

Parameters: None

Returns: List of { id, name, color, isDefaultCalendar, owner }

Graph endpoint: GET /v1.0/me/calendars


Timezone Resolution#

Timezone handling is a first-class concern given the cross-regional user base.

Resolution order: 1. Caller-supplied timezone parameter (if provided and valid IANA name) 2. User's mailbox timezone from GET /v1.0/me/mailboxSettings (fetched on-demand) 3. UTC (fallback if mailbox settings call fails)

Implementation: _resolve_timezone() in tools/calendar.py — shared by both list_events and get_event.

Graph mechanism: The Prefer: outlook.timezone="Europe/Amsterdam" request header instructs Microsoft Graph API to return all event datetimes in the requested timezone. No client-side timezone conversion is performed — all transformation is delegated to the Graph API (mitigates R6).


Security#

Authentication: OAuth2 OBO flow — see DESIGN:OAuthAuthentication

Authorization: - All Graph API calls use /me/ endpoint — access scoped to the authenticated user - No user ID parameters accepted from MCP clients (prevents cross-user access) - Microsoft Graph API provides secondary authorization layer

Graph Permission Scope: Calendars.Read only (no create, update, delete, or send permissions)

Data Protection: - Calendar event data never persisted — stateless HTTP mode (stateless_http=True) - DynamoDB stores OAuth tokens only — no event content, attendee data, or metadata - Event subject, body, attendee names/emails, and location fields excluded from application logs - LOG_LEVEL=INFO in production — DEBUG logging disabled - Transport: TLS 1.3 (enforced at ALB layer)

Attendee Privacy: - Attendee data (names, email addresses) returned only within the active MCP session - Not stored, cached, or logged anywhere in the system - User consented to Calendars.Read scope during OAuth2 authorization flow


Data Model#

No persistent data model for calendar content. The only data stored is OAuth tokens:

DynamoDB table: mcp-oauth-storage-outlook-mcp-{env}
  Partition key: user_id (string)
  Sort key:      session_id (string)
  Attributes:    access_token, refresh_token, expires_at
  TTL:           expires_at + 90 days

All calendar data is fetched on-demand from Microsoft Graph API and returned directly to the MCP client. No caching, no secondary storage. The calendar tools share the same DynamoDB table and OAuth infrastructure as the mail tools (single MCP server process).


Integrations#

Microsoft Graph API - Protocol: REST over HTTPS - Auth: OAuth2 Bearer token (OBO-exchanged, user-scoped) - Scopes: Calendars.Read, MailboxSettings.Read, offline_access - Base URL: https://graph.microsoft.com/v1.0 - Rate limits: 10,000 requests per 10 minutes per user - Error handling: HTTP 429 propagated to client (respects Retry-After); HTTP 401/403 re-raised; transient 5xx errors surfaced as MCP tool errors with user-friendly message

Azure AD (authentication) - See DESIGN:OAuthAuthentication for full flow - Tenant: Novo Nordisk single-tenant


Performance#

Expected Load: Low — per-user assistant sessions, not batch operations Response Time Target: < 2s for list_events / list_calendars, < 1.5s for get_event OBO Exchange Overhead: ~200–500ms per tool call (see DESIGN:OAuthAuthentication) Timezone Resolution Overhead: Additional ~200–400ms on first call per session if mailbox timezone must be fetched; subsequent calls within the same context can reuse the resolved timezone if the caller passes it explicitly

Optimization Notes: - No Graph token caching (security > latency) - Timezone fallback fetch only occurs when timezone parameter is omitted — callers can eliminate this overhead by passing a timezone explicitly - calendarView endpoint used for list_events (returns pre-expanded recurrence instances) rather than events + client-side expansion, avoiding complex recurrence logic


Deployment#

Environments:

Env URL ECS sizing Storage
Dev outlook.dev.connectors.novo-genai.com 0.25 vCPU / 512 MB Memory
Prod outlook.connectors.novo-genai.com 0.5 vCPU / 1 GB DynamoDB

The calendar tools are part of the same MCP server process and container as the mail tools — they share the same ECS service, ECR repo, and deployment workflow.

CI/CD: .github/workflows/deploy-outlook-mcp.yml - lintbuild (push to dev ECR) → deploy-devdeploy-prod (gated: environment: Production) - Prod promotes image from dev ECR (no rebuild)

Secrets (SSM Parameter Store):

/aiconnectors/{env}/outlook-mcp/client_id
/aiconnectors/{env}/outlook-mcp/client_secret
/aiconnectors/{env}/outlook-mcp/dynamodb_table_name


Monitoring#

Logs: JSON structured logs → CloudWatch Logs (/ecs/outlook-mcp-{env}) Retention: 90 days (GDPR Article 30 compliance)

Key Events Logged: - calendar_events_listed — user identity, date range, count, timezone used - calendar_event_fetched — user identity, event ID - calendars_listed — user identity, count - timezone_resolved — source (caller/mailbox/fallback), resolved value - graph_request — method, endpoint, HTTP status (no content) - obo_exchange_failed — error code, user identity

NOT Logged: - Event subject, body, location, or online meeting URLs - Attendee names or email addresses - OAuth tokens or authorization codes

Alerts: - Error rate > 5% over 5 minutes - Graph API 5xx rate > 10 per minute - ECS health check failures


Key Decisions#

Why calendarView instead of events for listing?#

Decision: list_events uses GET /v1.0/me/calendarView with startDateTime/endDateTime parameters rather than GET /v1.0/me/events.

Rationale: calendarView returns pre-expanded recurrence instances within the requested date window. events returns the recurrence master with a recurrence pattern object, requiring client-side expansion — which is complex, error-prone across DST boundaries, and unnecessary. Delegating recurrence expansion to Microsoft Graph API is the correct mitigation for R6 (timezone/recurrence parsing errors).

Why delegate timezone conversion to Graph API?#

Decision: Timezone conversion is handled entirely by the Prefer: outlook.timezone request header — no Python-side datetime arithmetic.

Rationale: Python timezone handling (especially across DST transitions and IANA vs Windows timezone name mismatches) is a known source of subtle bugs. The Graph API's timezone conversion is battle-tested and handles edge cases (DST transitions, all-day events, multi-day events) correctly. This directly mitigates R6.

Why _resolve_timezone() fetches mailbox settings lazily?#

Decision: If the caller omits timezone, the tool fetches the user's mailbox timezone from GET /v1.0/me/mailboxSettings rather than defaulting to UTC.

Rationale: Silently defaulting to UTC would cause wrong times for users in non-UTC timezones (the majority of Novo Nordisk users in Copenhagen/CET). The additional ~200–400ms is acceptable for the quality improvement. Callers can avoid the extra roundtrip by supplying timezone explicitly.

Why /me/ endpoint only (no /users/{id}/)?#

Decision: All Graph API calls use /me/ — no parameterized user ID paths.

Rationale: Same as DESIGN:OutlookMail — prevents cross-user access structurally (R3), avoids requiring Calendars.Read.All application permission, and ensures Microsoft Graph API enforces access from the OBO token's identity claim.


URS: @URS:OutlookCalendar Risk Assessment: @RISK:OutlookCalendar OAuth Design: @DESIGN:OAuthAuthentication Mail Design: @DESIGN:OutlookMail (shared ECS service, shared DynamoDB table)

Risks Addressed by This Design: - R1 (token compromise) — OBO flow, Calendars.Read scope only, TLS - R2 (data in logs) — structured logging with field exclusion, LOG_LEVEL=INFO - R3 (cross-user access) — /me/ endpoint only, no user ID parameters - R4 (attendee privacy) — no persistence, no logging of attendee fields, user consent via OAuth2 - R5 (Graph API availability) — graceful error messages, 429 Retry-After respected - R6 (timezone/recurrence errors) — calendarView endpoint + Prefer: outlook.timezone header


Version: 1.0 Date: 2026-04-22 Author: AILab Approved by: Pending PR review