Skip to content

Calendar Tools — QA Test Cases

Source: src/mcp_gee_sweet/tools/calendar.py

Fixtures: see docs/qa/setup.md. Substitute your {CALENDAR_ID} (a subscribed calendar the service account has access to) and {EVENT_ID} (a pre-existing event in that calendar) from fixtures.local.md.

Service account note: the service account must have been added to each calendar via calendarList().insert() before it can read or write events. list_calendars only returns calendars the service account has explicitly subscribed to — it will not see calendars shared with the owning user's personal account. See docs/project_calendar_setup.md in memory.


list_calendars

TC-CAL01: Returns subscribed calendars

Prompt

"List all calendars I have access to"

Checks - Returns at least one calendar - Each item has id, summary, time_zone, access_role, primary - The service account's own calendar (or the primary shared calendar) is present

Result (2026-08-18) ✅ — PR #625 (issue #466) refactor round: list_calendars now routes through the shared _get_cached_calendar_list helper. Called live; returned 11 calendars, each with id/summary/time_zone/access_role/primary populated, one entry with primary: true. Output shape unchanged from pre-refactor. list_events/get_event/list_all_events (also touched by this PR's _shape_event extraction) were spot-checked live in the same round with matching, unchanged output shapes — see PR comment for the round's full write-up.

Result (2026-09-04) ✅ PASS list_calendars returned [] initially (SA has zero subscriptions); after create_calendar it returns 1 entry with id/summary/time_zone/access_role/primary. Fixture {CALENDAR_ID} not shared with SA so used SA-owned cal.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kit: After refresh_cache (the first call returned a cached result from another server): list_calendars returned 3 calendars including the primary and mcp-gee-sweet-qa, each with id/summary/time_zone/access_role/primary. The service-account run (kai-sa) had no subscribed calendars.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-oauth (OAuth): 3 calendars, each with id, summary, time_zone, access_role, primary; fixture calendar listed.


TC-CAL02: primary flag

Prompt

"List calendars and tell me which one is marked as primary"

Checks - Exactly one calendar has primary: true - All others have primary: false - 🔍 Service account note: service accounts may have no primary calendar — primary may be false on all entries; note what is observed

Result (2026-09-04) ✅ PASS No calendar has primary:true; SACAL primary:false. Matches 🔍 SA note "service accounts may have no primary calendar".

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL03: Cache hit on second call

Prompt (run twice)

"List my calendars again"

Checks - Second call returns same results - Logs show Calendar list cache hit

Result (2026-09-04) ✅ PASS Two list_calendars calls returned byte-identical results. Cache-hit log not observable from this harness.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-oauth (OAuth): Cached calendar list (3 entries), then Calendar list cache hit, same 3 calendars.


TC-CAL04: Empty subscription list

Setup: a service account with no calendar subscriptions

Prompt

"List all calendars"

Checks - Returns [] — not an error - 🔍 Hard to test without a fresh service account — note if observed

Result (2026-09-04) ✅ PASS First-ever list_calendars (before any subscription) returned {"result":[]} — empty list, not an error. Matches TC-CAL04 (normally hard to test).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


get_calendar

TC-CAL05: Valid calendar ID

Prompt

"Get the details for calendar {CALENDAR_ID}"

Checks - Returns id, summary, description, time_zone, access_role, primary - time_zone is a valid IANA name (e.g. America/Los_Angeles) - No error field

Result (2026-09-04) ✅ PASS No (shape matches)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL06: calendar_id='primary'

Prompt

"Get the calendar details for 'primary'"

Checks - Returns calendar metadata for the service account's primary calendar - 🔍 Service account note: service accounts may not have a primary calendar — if so, expect {"error": ...}; note the actual response

Result (2026-09-04) ✅ PASS get_calendar('primary') -> {"error":"HttpError 404 ... notFound"}. Matches 🔍 SA note "service accounts may not have a primary calendar; expect error".

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL07: Cache hit on second call

Prompt (run twice)

"Get calendar {CALENDAR_ID} again"

Checks - Second call returns same result - Logs show Calendar cache hit: {CALENDAR_ID}

Result (2026-09-04) ✅ PASS Two get_calendar calls on SACAL returned identical results. Cache-hit log not observable. Fixture-ID variant 404s under SA.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL08: Non-existent calendar ID

Prompt

"Get the calendar with ID 'totally-invalid-cal-id@example.com'"

Checks - Returns {"error": "..."} — not a top-level exception - Error message is from the Calendar API

Result (2026-09-04) ✅ PASS get_calendar('totally-invalid-cal-id@example.com') -> {"error":""}, not a top-level exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


create_calendar

TC-CAL44: Create a secondary calendar ⚠️ destructive

Prompt

"Create a new calendar called 'mcp-gee-sweet-qa-lifecycle-test' with description 'QA scratch calendar' and timezone 'America/Los_Angeles'"

Checks - Returns id, summary, description, time_zone - summary is mcp-gee-sweet-qa-lifecycle-test - time_zone is America/Los_Angeles - The new calendar appears in a follow-up list_calendars call (cache invalidated)

Result (2026-07-05) ✅ — Created via OAuth; response had summary, description, and time_zone: "America/Los_Angeles" exactly as requested. Confirmed present in a follow-up list_calendars call.

Result (2026-09-04) ✅ PASS create_calendar -> id, summary=mcp-gee-sweet-qa-lifecycle-test, description="QA scratch calendar", time_zone=America/Los_Angeles. Appeared in follow-up list_calendars (cache invalidated). SA CAN own calendars. SACAL id b9e74820...a4313@group.calendar.google.com

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL45: Create with only summary

Prompt

"Create a new calendar called 'mcp-gee-sweet-qa-minimal'"

Checks - Succeeds with only summary provided - description is null/absent - time_zone reflects the account default

Result (2026-07-05) ✅ — Created with only summary provided. description: null. time_zone: "UTC" (account default rather than the creator's local timezone — noted for documentation purposes). Confirmed present in a follow-up list_calendars call.

Result (2026-09-04) ✅ PASS create_calendar summary-only -> id, summary=mcp-gee-sweet-qa-minimal, description=null, time_zone="UTC" (account default). MINCAL id 41a5983c...30c1@group.calendar.google.com

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


update_calendar

TC-CAL46: Rename and change timezone ⚠️ destructive

Setup: use the calendar created in TC-CAL44

Prompt

"Update calendar 'mcp-gee-sweet-qa-lifecycle-test' — rename it to 'mcp-gee-sweet-qa-renamed' and set its timezone to 'America/New_York'"

Checks - summary is mcp-gee-sweet-qa-renamed - time_zone is America/New_York - description unchanged from TC-CAL44 - get_calendar on the same ID reflects the new values

Result (2026-07-05) ✅ — Patched with summary: "mcp-gee-sweet-qa-renamed" and timezone: "America/New_York". Response reflected both changes; description unchanged from TC-CAL44 ("QA scratch calendar").

Result (2026-09-04) ✅ PASS update_calendar summary+timezone on SACAL -> summary=mcp-gee-sweet-qa-renamed, time_zone=America/New_York, description unchanged. get_calendar confirms new values.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL47: Change color only ⚠️ destructive

Setup: use the calendar from TC-CAL44/46

Prompt

"Change the color of calendar 'mcp-gee-sweet-qa-renamed' to color ID '5'"

Checks - color_id in the response is 5 - summary/description/time_zone unchanged (verify with get_calendar) - Confirms the color patch goes through calendarList().patch(), not calendars().patch()

Result (2026-07-05) ✅ — Patched color_id="5" only (no summary/description/timezone). Response had color_id: "5" with summary, description, time_zone all unchanged from TC-CAL46 — confirms the tool fell back to calendars().get() for the base fields and routed color through calendarList().patch().

Result (2026-09-04) ✅ PASS update_calendar color_id="5" only -> response color_id="5"; summary/description/time_zone unchanged from CAL46. Color routed via calendarList().patch().

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL48: No fields provided

Prompt

"Update calendar {CALENDAR_ID} with no changes — just fetch its current state through update_calendar"

Checks - Returns current calendar metadata unchanged - color_id is null (no color patch attempted) - No API error

Result (2026-07-05) ✅ — Called with calendar_id="primary" and no other fields. Returned the account's real primary calendar summary/timezone unchanged, color_id: null. Confirms the no-op path uses calendars().get() without issuing any patch.

Result (2026-09-04) ✅ PASS update_calendar with no fields on SACAL -> current metadata unchanged, color_id=null (no-op path uses calendars().get(), no patch), no API error.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL49: Non-existent calendar ID

Prompt

"Update calendar 'totally-invalid-cal-id@example.com' — rename it to 'Nope'"

Checks - Returns {"error": "..."} — not a top-level exception

Result (2026-07-05) ✅ — Returned {"error": "<HttpError 404 ... 'reason': 'notFound' ...>"}. No exception raised.

Result (2026-09-04) ✅ PASS update_calendar on invalid ID -> {"error":""}, no top-level exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


delete_calendar

TC-CAL50: Delete a calendar ⚠️ destructive

Setup: use the calendar created in TC-CAL44 (after TC-CAL46/47 updates)

Prompt

"Delete the calendar 'mcp-gee-sweet-qa-renamed'"

Checks - Returns {"calendar_id": "...", "action": "deleted"} - Calendar no longer appears in a follow-up list_calendars call - get_calendar on the deleted ID returns an error

Result (2026-07-05) ✅ — Returned {"calendar_id": "...", "action": "deleted"}. Confirmed absent from a follow-up list_calendars call. get_calendar on the deleted ID returned a 404 notFound error.

Result (2026-09-04) ✅ PASS delete_calendar SACAL -> {"calendar_id":...,"action":"deleted"}. Absent from follow-up list_calendars (now []). get_calendar on deleted ID -> HttpError 404 notFound.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL51: Delete the minimal test calendar ⚠️ destructive

Setup: use the calendar created in TC-CAL45

Prompt

"Delete the calendar 'mcp-gee-sweet-qa-minimal'"

Checks - Returns {"calendar_id": "...", "action": "deleted"} - Cleans up the fixture so it doesn't linger in the account's calendar list

Result (2026-07-05) ✅ — Returned {"calendar_id": "...", "action": "deleted"}. Confirmed removed from the account's list_calendars output alongside TC-CAL50's cleanup.

Result (2026-09-04) ✅ PASS delete_calendar MINCAL -> {"calendar_id":...,"action":"deleted"}. list_calendars now [] — fixture cleaned up.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL52: Non-existent calendar ID

Prompt

"Delete the calendar 'totally-invalid-cal-id@example.com'"

Checks - Returns {"error": "..."} — not a top-level exception - No side effects

Result (2026-07-05) ✅ — Returned {"error": "<HttpError 404 ... 'reason': 'notFound' ...>"}. No side effects.

Result (2026-09-04) ✅ PASS delete_calendar 'totally-invalid-cal-id@example.com' -> {"error":""}, not a top-level exception, no side effects.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


add_calendar_to_list

Note on test targets: subscribing/unsubscribing only makes sense for a calendar you don't own — Google's API rejects unsubscribing from a calendar you're the owner of (see TC-CAL59). Public holiday calendars (e.g. en.japanese#holiday@group.v.calendar.google.com, en.canadian#holiday@group.v.calendar.google.com) are convenient, safe, reversible targets: not owned by any test account, freely subscribable, and already used elsewhere in this account for en.usa#holiday@group.v.calendar.google.com.

TC-CAL53: Subscribe to an existing calendar ⚠️ destructive

Setup: a public calendar not currently in your list, e.g. en.japanese#holiday@group.v.calendar.google.com (Holidays in Japan)

Prompt

"Subscribe me to calendar 'en.japanese#holiday@group.v.calendar.google.com'"

Checks - Returns id, summary, time_zone, access_role, primary, color_id - Calendar now appears in a follow-up list_calendars call (cache invalidated)

Result (2026-07-05) ✅ — Response had summary: "Holidays in Japan", access_role: "reader", color_id: "8" (Google's default color for this public calendar). Confirmed present in a follow-up list_calendars call.

Result (2026-09-04) ✅ PASS add_calendar_to_list en.japanese#holiday -> id/summary="Holidays in Japan"/time_zone/access_role="reader"/primary=false/color_id="8". Present in follow-up list_calendars.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL54: Subscribe with a color ⚠️ destructive

Setup: another public calendar not yet in your list, e.g. en.canadian#holiday@group.v.calendar.google.com (Holidays in Canada)

Prompt

"Subscribe me to calendar 'en.canadian#holiday@group.v.calendar.google.com' with color ID '7'"

Checks - color_id in the response is 7 - Calendar appears in list_calendars

Result (2026-07-05) ✅ — Response had color_id: "7" and summary: "Holidays in Canada". Confirmed present in list_calendars.

Result (2026-09-04) ✅ PASS add_calendar_to_list en.canadian#holiday color_id="7" -> color_id="7", summary="Holidays in Canada". Present in list_calendars.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL55: Non-existent calendar ID

Prompt

"Subscribe me to calendar 'totally-invalid-cal-id@example.com'"

Checks - Returns {"error": "..."} — not a top-level exception - No entry added to the calendar list

Result (2026-07-05) ✅ — Returned {"error": "<HttpError 404 ... 'reason': 'notFound' ...>"}.

Result (2026-09-04) ✅ PASS add_calendar_to_list invalid ID -> {"error":""}, no top-level exception; no entry added.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


remove_calendar_from_list

TC-CAL56: Unsubscribe from a calendar ⚠️ destructive

Setup: use the calendar subscribed to in TC-CAL53

Prompt

"Remove calendar 'en.japanese#holiday@group.v.calendar.google.com' from my calendar list"

Checks - Returns {"calendar_id": "...", "action": "removed_from_list"} - Calendar no longer appears in a follow-up list_calendars call

Result (2026-07-05) ✅ — Returned {"calendar_id": "en.japanese#holiday@group.v.calendar.google.com", "action": "removed_from_list"}. Confirmed absent from a follow-up list_calendars call (the still-subscribed "Holidays in Canada" from TC-CAL54 remained, confirming only the targeted subscription was removed).

Result (2026-09-04) ✅ PASS remove_calendar_from_list en.japanese#holiday -> {"calendar_id":...,"action":"removed_from_list"}. Absent from follow-up list_calendars; Canada subscription retained.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL57: Unsubscribing does not delete the calendar ⚠️ destructive

Setup: the calendar removed from the list in TC-CAL56

Prompt

"Subscribe me again to calendar 'en.japanese#holiday@group.v.calendar.google.com'"

Checks - add_calendar_to_list succeeds and returns the same summary as TC-CAL53 - Confirms remove_calendar_from_list only removed the subscription — the underlying calendar was never affected and is still fully subscribable

Result (2026-07-05) ✅ — Re-subscribed successfully; response had the identical summary: "Holidays in Japan" as TC-CAL53. Confirms remove_calendar_from_list does not touch the calendar resource itself.

Result (2026-09-04) ✅ PASS Re-subscribed to en.japanese#holiday -> identical summary "Holidays in Japan". Confirms remove_calendar_from_list left calendar resource untouched.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL58: Non-existent calendar ID

Prompt

"Remove calendar 'totally-invalid-cal-id@example.com' from my calendar list"

Checks - Returns {"error": "..."} — not a top-level exception - No side effects

Result (2026-07-05) ✅ — Returned {"error": "<HttpError 404 ... 'reason': 'notFound' ...>"}.

Result (2026-09-04) ✅ PASS remove_calendar_from_list invalid ID -> {"error":""}, no top-level exception, no side effects.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL59: Cannot unsubscribe from a calendar you own

Setup: a calendar owned by the authenticated user (e.g. a fresh calendar from create_calendar)

Prompt

"Remove calendar {OWNED_CALENDAR_ID} from my calendar list"

Checks - Returns {"error": "..."} — not a top-level exception - The error message is actionable and names delete_calendar as the correct tool — remove_calendar_from_list detects the cannotUnsubscribeFromOwnedCalendar reason specifically and substitutes a friendly message instead of passing through the raw HttpError repr

Result (2026-07-05) ✅ — First pass (before the friendly-message special-case was added) returned the raw passthrough: {"error": "<HttpError 403 ... 'reason': 'cannotUnsubscribeFromOwnedCalendar', 'message': 'The data owner of a calendar cannot remove such a calendar from their calendar list.' ...>"}. After adding the special-case (reviewer feedback on #269), re-ran against a freshly created owned calendar and got {"error": "Google does not allow removing a calendar you own from your own calendar list (reason: cannotUnsubscribeFromOwnedCalendar). Use delete_calendar instead to permanently delete it."} — confirms the friendlier message is live and correctly names the fix.

Result (2026-10-05) ✅ PASS (PR #912 round 2, Kit). Regression check after remove_calendar_from_list moved onto the shared _http_error_has_reason, which matches the quoted reason. On a freshly created owned calendar it still returns the cannotUnsubscribeFromOwnedCalendar message naming delete_calendar.

Result (2026-09-04) ✅ PASS remove_calendar_from_list on SA-owned MINCAL -> {"error":"Google does not allow removing a calendar you own from your own calendar list (reason: cannotUnsubscribeFromOwnedCalendar). Use delete_calendar instead to permanently delete it."} — friendly, actionable, names delete_calendar.

Result (2026-10-12) ✅ PASS (PR #1011 round 1, Sky, against 1a8053e after a fresh reconnect). Regression check after remove_calendar_from_list moved onto the shared http_error_has_reason in tools/http_errors.py (#916). On a freshly created owned calendar it still returns the cannotUnsubscribeFromOwnedCalendar message naming delete_calendar.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


list_calendar_acl

TC-CAL60: List ACL rules on a calendar

Prompt

"List the access control rules for calendar {CALENDAR_ID}"

Checks - Returns a list with at least one rule (typically the owner's own user rule with role: owner) - Each item has id, role, scope_type, scope_value

Result: PASS (live, disposable calendar, 2026-07-29). Re-verified PASS (live, 2026-08-16, PR #612 issue #460) — returned 4 rules on kevin.huisman@gmail.com, each with all four fields.

Result (2026-09-04) ✅ PASS No (same shape, different rule count since real fixture is narrowly shared)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL61: Non-existent calendar ID

Prompt

"List the ACL rules for calendar 'totally-invalid-cal-id@example.com'"

Checks - Returns [{"error": "..."}] — not a top-level exception

Result: PASS (live, 2026-07-29). Re-verified PASS (live, 2026-08-16, PR #612 issue #460) — invalid ID against the now-paginated tool still returns [{"error": "<HttpError 404 ...>"}], not a top-level exception.

Result (2026-09-04) ✅ PASS list_calendar_acl(invalid id) -> [{"error":""}], not a top-level exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL76: Pagination — a calendar with more ACL rules than one page must not be truncated (issue #460)

Setup: requires a calendar with more than one page of ACL rules (Calendar API pages acl().list() at up to 100 items per page). If no such fixture calendar exists in this account, SKIP and record as environmental — this behavior is covered by tests/test_calendar.py::TestListCalendarAcl::test_follows_next_page_token_across_pages at the unit level, which mocks two pages directly.

Prompt

"List the access control rules for calendar {MANY_RULES_CALENDAR_ID}"

Checks - Call list_calendar_acl(calendar_id="{MANY_RULES_CALENDAR_ID}") - The returned list's length matches the calendar's actual total rule count (cross-check against the Google Calendar sharing UI or gcloud/API count), not capped at 100 - No duplicate id values across the returned list (would indicate a page was re-fetched instead of advancing pageToken)

Result: SKIP (environmental, 2026-08-16, PR #612) — no calendar in this account's fixture set has more than one page (100+) of ACL rules; list_calendars shows only a handful of personal/family calendars with at most a few shares each. Covered at the unit level by tests/test_calendar.py::TestListCalendarAcl::test_follows_next_page_token_across_pages, confirmed passing (uv run python -m pytest tests/test_calendar.py -k TestListCalendarAcl — 3 passed; full file — 74 passed).

Result (2026-09-04) ⏭️ SKIP No calendar in reach has 100+ ACL rules (SA owns 2 fresh calendars only). Covered by unit test test_calendar.py::TestListCalendarAcl::test_follows_next_page_token_across_pages. Same as prior runs.

Result (2026-10-17) ⏭️ SKIP — v0.9.2 release pass No calendar with 100+ ACL rules; the unit test passes.


add_calendar_acl

TC-CAL62: Add a reader rule for a user ⚠️ destructive

Setup: a calendar you own (e.g. from create_calendar) and an email address not already on its ACL, e.g. test@example.com

Prompt

"Grant reader access to test@example.com on calendar {CALENDAR_ID}"

Checks - Returns id in the form user:test@example.com, role: "reader", scope_type: "user", scope_value: "test@example.com" - The rule appears in a follow-up list_calendar_acl call

Cleanup: remove the rule via remove_calendar_acl if not reused by TC-CAL67.

Result: PASS (live, 2026-07-29).

Result (2026-09-04) ✅ PASS No

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL63: Add a public (default-scope) rule ⚠️ destructive

Setup: a disposable test calendar (e.g. from create_calendar) — do not use a real personal calendar, since scope_type='default' makes the calendar's free/busy or event details public on the internet

Prompt

"Make calendar {TEST_CALENDAR_ID} publicly readable for free/busy using scope_type 'default' and role 'freeBusyReader', with no scope_value"

Checks - Returns scope_type: "default", role: "freeBusyReader" - id is "default" - scope_value is not required when scope_type='default'

Result: PASS with a corrected expectation (live, 2026-07-29) — the live API returns scope_value: "__public_principal__@public.calendar.google.com" for a default-scope rule, not null as originally written here. That's a Calendar API quirk (its documented sentinel for the public principal), not a tool defect — add_calendar_acl's own docstring never promises a null/omitted scope_value for default, only that scope_value isn't required as an input, which held. Corrected the check above to drop the scope_value: null claim.

Result (2026-09-04) ✅ PASS add_calendar_acl freeBusyReader/default, no scope_value on SACAL -> id="default", role=freeBusyReader, scope_type=default, scope_value="public_principal@public.calendar.google.com" (documented API sentinel; matches corrected expectation). Rule removed in cleanup.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL77: scope_value rejected when scope_type is 'default' (issue #458)

Prompt

"Add a calendar ACL rule on {CALENDAR_ID} with role 'freeBusyReader', scope_type 'default', and scope_value 'someone@example.com'"

Checks - Call add_calendar_acl(calendar_id="{CALENDAR_ID}", role="freeBusyReader", scope_type="default", scope_value="someone@example.com") - Returns {"error": "..."} naming scope_value and 'default' — not a silently-accepted rule with the value ignored (the prior behavior) - No new rule appears in a follow-up list_calendar_acl call

Result: PASS (live, 2026-08-16) — add_calendar_acl(calendar_id="primary", role="freeBusyReader", scope_type="default", scope_value="someone@example.com") returned {"error": "scope_value must not be set when scope_type is 'default'."}. Follow-up list_calendar_acl(calendar_id="primary") showed no new rule — the 4 existing rules were unchanged. No dedicated QA fixture calendar exists yet (tracked under #304); tested against primary instead, which is safe here since the validation short-circuits before any Calendar API call is made.

Result (2026-09-04) ✅ PASS add_calendar_acl default + scope_value="someone@example.com" -> {"error":"scope_value must not be set when scope_type is 'default'."} Names scope_value and 'default'. No rule added (validation short-circuits before API).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL64: Invalid role rejected without calling the API

Prompt

"Add a calendar ACL rule for test@example.com on {CALENDAR_ID} with role 'admin'"

Checks - Returns {"error": "..."} naming the invalid role and listing the valid ones - No new rule appears in a follow-up list_calendar_acl call — confirms the invalid role is rejected before any API call

Result: PASS (live, 2026-07-29).

Result (2026-09-04) ✅ PASS add_calendar_acl role="admin" -> {"error":"Invalid role 'admin'. Must be one of: reader, writer, owner, freeBusyReader"} — names invalid role + lists valid ones. No rule added (confirmed via later list_calendar_acl).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL65: scope_value required for a non-default scope_type

Prompt

"Add a calendar ACL rule on {CALENDAR_ID} with role 'reader' and scope_type 'user' but no scope_value"

Checks - Returns {"error": "..."} naming scope_value as required - No new rule appears in a follow-up list_calendar_acl call

Result: PASS (live, 2026-07-29).

Result (2026-09-04) ✅ PASS add_calendar_acl scope_type="user", no scope_value -> {"error":"scope_value is required when scope_type is 'user'."} No rule added.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL66: Non-existent calendar ID

Prompt

"Grant reader access to test@example.com on calendar 'totally-invalid-cal-id@example.com'"

Checks - Returns {"error": "..."} — not a top-level exception

Result: PASS (live, 2026-07-29).

Result (2026-09-04) ✅ PASS add_calendar_acl on invalid calendar ID -> {"error":""}, not a top-level exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


remove_calendar_acl

TC-CAL67: Remove an ACL rule ⚠️ destructive

Setup: the rule created in TC-CAL62 (or a fresh add_calendar_acl call if run independently)

Prompt

"Remove the ACL rule 'user:test@example.com' from calendar {CALENDAR_ID}"

Checks - Returns {"calendar_id": "...", "rule_id": "...", "action": "removed"} - The rule no longer appears in a follow-up list_calendar_acl call

Result: PASS (live, 2026-07-29).

Result (2026-09-04) ✅ PASS No

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL68: Non-existent rule ID

Prompt

"Remove ACL rule 'totally-invalid-rule-id' from calendar {CALENDAR_ID}"

Checks - Returns {"error": "..."} — not a top-level exception

Result: PASS (live, 2026-07-29) — API returns 400 "Invalid resource id value" (malformed ID), correctly surfaced as {"error": ...} rather than a top-level exception. A well-formed but nonexistent rule ID would presumably 404 the same clean way, not separately exercised.

Result (2026-09-04) ✅ PASS remove_calendar_acl rule_id="totally-invalid-rule-id" -> {"error":""}, not a top-level exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


list_events

TC-CAL09: No time filters — upcoming events

Prompt

"List events in calendar {CALENDAR_ID} with no time filter"

Checks - Returns events ordered by start time - Events are from now onward (no past events) - Each event has id, summary, start, end, status

Result (2026-09-04) ✅ PASS list_events no time filter on SACAL -> {"result":[]} (all seeded events are before "now" 2026-09-04, correctly excluded — confirms default time_min=now, no past events). Field/ordering checks validated via July-windowed call: 3 events correctly ordered by start, each with id/summary/start/end/status.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).

Result (2026-10-19) ⚠️ PARTIAL — v0.9.3 release pass kit: No-filter list_events on {CALENDAR_ID} returned [] (no events seeded after now). A July window returned the TC-CAL20 event with id/summary/start/end/status. Not checked: ordering, with only one event in range.

Result (2026-10-20) ⚠️ PARTIAL — v0.9.3 post-advisory re-check @ c90b88d3 kai-oauth (OAuth): no future events on the fixture calendar ([]); a July window returned the TC-CAL20 event with the required fields. Ordering not checkable with one event.


TC-CAL10: time_min + time_max window

Prompt

"List events in {CALENDAR_ID} between 2026-06-01T00:00:00Z and 2026-06-30T23:59:59Z"

Checks - All returned events fall within the specified window - Events outside the window are absent - Empty list returned if no events exist in that window — not an error

Result (2026-09-04) ✅ PASS list_events 2026-06-01..2026-06-30 window -> {"result":[]} — July events absent, empty not an error.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


Prompt

"List events in {CALENDAR_ID} matching the search term 'dinner'"

Checks - Returns only events whose summary, description, or location contains 'dinner' - Non-matching events excluded - Empty list if nothing matches — not an error

Result (2026-09-04) ✅ PASS list_events query="dinner" -> {"result":[]} — no matches, empty not an error.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL12: All-day event format

Setup: a calendar containing at least one all-day event

Prompt

"List events in {CALENDAR_ID} and show me any all-day events"

Checks - All-day event start and end are date strings (YYYY-MM-DD), not datetime strings - No T or timezone offset in the date values

Result (2026-09-04) ✅ PASS QA-AllDay-Test in list_events: start="2026-07-02", end="2026-07-03" — date strings, no "T"/offset. Confirms date field used.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL13: Timed event format

Prompt

"List events in {CALENDAR_ID} and show me a timed event's start and end"

Checks - Timed event start and end are RFC 3339 datetime strings (contain T) - Timezone offset or Z is present

Result (2026-09-04) ✅ PASS QA-Timed-Test in list_events: start="2026-07-01T13:00:00-04:00" — contains "T", has timezone offset.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL14: max_results clamped

Prompt

"List events in {CALENDAR_ID} with max_results=0" then "List events with max_results=3000"

Checks - max_results=0 → clamped to 1; at most 1 event returned - max_results=3000 → clamped to 2500; no more than 2500 events returned

Result (2026-09-04) ✅ PASS max_results=0 -> exactly 1 event returned (clamped to 1). max_results=3000 -> all 3 events, no error (<=2500; exact 2500 cap not reproducible w/o 2500+ events).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL15: Non-existent calendar ID

Prompt

"List events in calendar 'invalid-cal-id@example.com'"

Checks - Returns [{"error": "..."}] — not a top-level exception - Error message is from the Calendar API

Result (2026-09-04) ✅ PASS list_events on 'invalid-cal-id@example.com' -> [{"error":""}], not a top-level exception, message from Calendar API.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


get_event

TC-CAL16: Valid event — full details

Note (v0.9.0): on a group/secondary calendar (the fixture {CALENDAR_ID}), organizer is the calendar's own ID string (email-shaped but not a person's mailbox), not a personal account's email — confirmed correct on both an SA-owned substitute calendar and the real fixture. Check reworded to not assume a personal-calendar shape.

Prompt

"Get the details of event {EVENT_ID} from calendar {CALENDAR_ID}"

Checks - Returns id, summary, start, end, location, description, organizer, attendees, recurrence, html_link, status, created, updated - html_link opens the event in Google Calendar UI - organizer is a well-formed identifier string (a personal account's email on a personal calendar; the calendar's own ID string, email-shaped, on a group/secondary calendar)

Result (2026-09-04) ✅ PASS No — confirms same organizer-is-calendar-ID finding on the real fixture

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL17: Attendees populated

Setup: an event with at least one attendee

Prompt

"Get event {EVENT_ID} and list the attendees"

Checks - attendees is a non-empty list - Each attendee has email and response (e.g. accepted, needsAction)

Result (2026-09-04) ✅ PASS YES — SA SKIPped (couldn't invite attendees); OAuth succeeds cleanly

Result (2026-10-17) ⏭️ SKIP — v0.9.2 release pass create_event with attendees gives 403 forbiddenForServiceAccounts under SA.


TC-CAL18: Recurring event instance

Setup: a recurring event in the calendar

Prompt

"Get a recurring event instance from {CALENDAR_ID} and check its recurrence field"

Checks - recurrence is a non-null list (e.g. ["RRULE:FREQ=WEEKLY;..."]) - Single (non-recurring) events have recurrence: null

Result (2026-09-04) ✅ PASS Instance get_event (dmp5oe7...q_20260713T160000Z) -> recurrence: null. Master get_event -> recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"] (non-null list). One-off QA-OneOff -> recurrence: null.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL19: Non-existent event ID

Prompt

"Get event 'invalidEventId999xyz' from calendar {CALENDAR_ID}"

Checks - Returns {"error": "..."} — not a top-level exception - Error message references the bad event ID

Result (2026-09-04) ✅ PASS get_event event_id='invalidEventId999xyz' -> {"error":""}, not a top-level exception; URL/message references the bad event ID.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


create_event

TC-CAL20: Timed event ⚠️ destructive

Prompt Playwright: required

"Create an event called 'QA-Timed-Test' in {CALENDAR_ID} on 2026-07-01 from 10:00am to 11:00am Pacific time"

Checks - Event created successfully - start and end are RFC 3339 datetime strings with timezone offset - html_link is present and opens the event in Google Calendar - status is confirmed - calendar_cache.mark_dirty called — next list_events re-fetches

Result (2026-09-04) ⚠️ PARTIAL create_event timed on SACAL -> id, start/end RFC3339 w/ offset (2026-07-01T13:00:00-04:00, rendered in cal tz America/New_York = 10-11am PT), html_link present, status=confirmed, recurrence=null. Follow-up windowed list_events re-fetched and showed it (mark_dirty proxy). Playwright: OAuth-logged-in browser cannot see SA-owned-calendar events (cal not shared to/listed by OAuth user) — API used per shard fallback. EVENT_TIMED=dcfqjj57pv7kb1tpee7e61v65c Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run (the owner browser can't see SA-owned calendars).

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kit: create_event 2026-07-01 10:00–11:00 PT: RFC 3339 start/end with offset, html_link, status confirmed; a July list_events re-fetched it. Playwright (2026-10-19-tc-cal20.png): the event opens from html_link on Jul 1, 5–6pm in the GMT+0 browser (10–11am PT). Event deleted.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-oauth (OAuth): event created with correct start/end and html_link, Marked calendar cache dirty logged, then deleted. API checks only. Stale wording: list_events isn't cached, so there's no re-fetch line to see.


TC-CAL21: All-day event ⚠️ destructive

Prompt Playwright: required

"Create an all-day event called 'QA-AllDay-Test' in {CALENDAR_ID} on 2026-07-02"

Checks - start and end in response are date strings (2026-07-02, 2026-07-03) - No T in the date values — confirms date field used, not dateTime - Event appears in list_events for that day

Result (2026-09-04) ✅ PASS create_event all-day -> start="2026-07-02", end="2026-07-03", no "T" (date fields). Appears in windowed list_events for that day. EVENT_ALLDAY=9m4dfmdjnjl9742ui2hnt7oqm8

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL22: With description, location, and attendees ⚠️ destructive

Prompt Playwright: required

"Create an event called 'QA-Full-Test' in {CALENDAR_ID} for 2026-07-03 2pm-3pm PT with description 'QA test event', location 'Conference Room A', and attendee test@example.com"

Checks - All three optional fields present on the created event (verify with get_event) - attendees contains test@example.com - location is Conference Room A - description is QA test event

Result (2026-09-04) ✅ PASS YES — SA got 403 forbiddenForServiceAccounts; OAuth succeeds cleanly, verified live + Playwright

Result (2026-10-17) ⏭️ SKIP — v0.9.2 release pass create_event with attendees gives 403 forbiddenForServiceAccounts under SA.


TC-CAL23: Invalid calendar ID

Prompt

"Create an event called 'QA-BadCal' in calendar 'invalid-cal@example.com' for tomorrow"

Checks - Returns {"error": "..."} — not a top-level exception - No event created

Result (2026-09-04) ✅ PASS create_event on 'invalid-cal@example.com' -> {"error":""}, no event created.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars).


update_event

TC-CAL24: Update summary only ⚠️ destructive

Prompt Playwright: required

"Update the event created in TC-CAL20 — change the title to 'QA-Timed-Updated'"

Checks - summary in response is QA-Timed-Updated - start, end, location, description unchanged (verify with get_event) - calendar_cache.mark_dirty called

Result (2026-09-04) ✅ PASS update_event summary -> "QA-Timed-Updated"; start/end unchanged (13:00-14:00-04:00); location/description still null.

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL25: Update start and end ⚠️ destructive

Prompt Playwright: required

"Update the event from TC-CAL20 — move it to 2026-07-01 from 2pm to 3pm PT"

Checks - start and end reflect the new times - summary and other fields unchanged

Result (2026-09-04) ✅ PASS update_event start/end -> 17:00-18:00-04:00 (2pm-3pm PT); summary unchanged.

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL26: Update description and location ⚠️ destructive

Prompt Playwright: required

"Update the event from TC-CAL22 — change the description to 'Updated desc' and location to 'Room B'"

Checks - description is Updated desc - location is Room B - Other fields (summary, attendees) unchanged

Result (2026-09-04) ✅ PASS update_event description="Updated desc", location="Room B". get_event confirms both; summary ("QA-Full-Test") and attendees ([]) unchanged.

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL27: Non-existent event ID

Prompt

"Update event 'invalidEventId999xyz' in {CALENDAR_ID} — change the summary to 'Test'"

Checks - Returns {"error": "..."} — not a top-level exception

Result (2026-09-04) ✅ PASS update_event on 'invalidEventId999xyz' -> {"error":""}, not a top-level exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars).


delete_event

TC-CAL28: Delete an existing event ⚠️ destructive

Prompt Playwright: required

"Delete the event created in TC-CAL21 from {CALENDAR_ID}"

Checks - Returns {"calendar_id": "...", "event_id": "...", "action": "deleted"} - Event no longer appears in list_events for that date - calendar_cache.mark_dirty called

Result (2026-09-04) ✅ PASS delete_event -> {"calendar_id":...,"event_id":"9m4dfmdjnjl9742ui2hnt7oqm8","action":"deleted"}. Absent from follow-up windowed list_events (empty).

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL29: Non-existent event ID

Prompt

"Delete event 'invalidEventId999xyz' from {CALENDAR_ID}"

Checks - Returns {"error": "..."} — not a top-level exception - No side effects

Result (2026-09-04) ✅ PASS delete_event on 'invalidEventId999xyz' -> {"error":""}, not a top-level exception, no side effects.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars).


find_free_slots

TC-CAL30: Single calendar — no events in window

Prompt

"Find free slots for calendar {CALENDAR_ID} between 2026-07-04T00:00:00Z and 2026-07-04T23:59:59Z" (use a date with no events)

Checks - busy[{CALENDAR_ID}] is an empty list - free_slots covers the entire window as one slot: [{start: time_min, end: time_max}]

Result (2026-09-04) ✅ PASS find_free_slots SACAL 2026-07-04 full-day, no events -> busy[SACAL]=[], free_slots=[{2026-07-04T00:00:00Z .. 2026-07-04T23:59:59Z}] (whole window one slot).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL31: Single calendar — events in window

Prompt

"Find free slots for {CALENDAR_ID} on 2026-07-01 from 9am to 5pm PT" (ensure TC-CAL20/25 events exist in this window)

Checks - busy[{CALENDAR_ID}] contains the event's start and end times - free_slots shows the gaps before, between, and after busy periods - free_slots start/end values are RFC 3339 strings

Result (2026-09-04) ✅ PASS find_free_slots SACAL 2026-07-01 16:00-00:00Z (9am-5pm PT) -> busy[SACAL]=[{21:00:00Z..22:00:00Z}] (the CAL25 event); free_slots gaps before {16:00-21:00Z} and after {22:00-00:00Z}, all RFC3339.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL32: Multiple calendar IDs

Setup: two calendars the service account has access to

Prompt

"Find free slots across {CALENDAR_ID} and {CALENDAR_ID_2} for 2026-07-01 9am–5pm PT"

Checks - busy has a key for each calendar ID - free_slots reflects the union of all busy times — a slot is free only when all calendars are free - No error if one calendar has no events (that calendar's busy list is just empty)

Result (2026-09-04) ✅ PASS find_free_slots [SACAL, MINCAL] same window -> busy has a key per calendar; MINCAL busy=[] (no events, no error); free_slots = union (only SACAL busy considered).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL33: Invalid calendar ID in list (issue #691: error shape aligned with list_all_events)

Background: originally busy["invalid-cal@example.com"] was a bare [{"error": "notFound"}] — just the freebusy API's reason string, no calendar_id/calendar_summary, unlike list_all_events's per-calendar error entry for the identical failure ({calendar_id, calendar_summary, error}). Fixed by aligning the shape: calendar_id and calendar_summary (falling back to the id when the calendar isn't in the user's calendar list) are now included, and error also folds in the API's domain when present ("<reason> (<domain>)").

Prompt

"Find free slots for calendars [{CALENDAR_ID}, 'invalid-cal@example.com'] for the next hour"

Checks - busy["invalid-cal@example.com"] is [{"calendar_id": "invalid-cal@example.com", "calendar_summary": "invalid-cal@example.com", "error": "notFound"}] (or "notFound (<domain>)" if the API includes a domain) — not a bare {"error": "notFound"} - busy[{CALENDAR_ID}] is still populated correctly - Top-level response is not an error — partial results returned

Result (2026-09-10) ✅ PASS (re-verified under #691's richer error shape) — verified live via mcp-gee-sweet-kit: find_free_slots([{qa-cal}, "invalid-cal@example.com"], next hour) → busy["invalid-cal@example.com"] == [{"calendar_id": "invalid-cal@example.com", "calendar_summary": "invalid-cal@example.com", "error": "notFound (global)"}] (id-fallback summary, since the invalid id is never in the calendar list — matches list_all_events' behavior for a not-in-list calendar); busy[{qa-cal}] == [] (correctly populated, empty); top-level response not an error, free_slots covers the full window (partial results).

Prior run (2026-09-04), before the fix: find_free_slots [SACAL, 'invalid-cal@example.com'] -> busy["invalid-cal@example.com"]=[{"error":"notFound"}], busy[SACAL] still populated, top-level not an error (partial results).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL34: free_slots covers full window when no busy times

Prompt

"Find free slots for {CALENDAR_ID} between 2026-07-10T00:00:00Z and 2026-07-10T01:00:00Z" (no events in this window)

Checks - free_slots is exactly [{"start": "2026-07-10T00:00:00Z", "end": "2026-07-10T01:00:00Z"}] - Confirms the complement logic handles the empty-busy case

Result (2026-09-04) ✅ PASS find_free_slots SACAL 2026-07-10T00:00:00Z..01:00:00Z, no events -> free_slots exactly [{"start":"2026-07-10T00:00:00Z","end":"2026-07-10T01:00:00Z"}].

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL35: Contiguous busy periods merged in free_slots

Setup: two back-to-back events with no gap (or overlapping events)

Prompt

"Find free slots for {CALENDAR_ID} over a period with two adjacent events"

Checks - The two busy periods are merged in the free slot calculation - free_slots does not include a zero-length or negative-length gap between them - Confirms the interval merge logic in find_free_slots

Result (2026-09-04) ✅ PASS Two adjacent events (14:00-15:00Z, 15:00-16:00Z) -> busy merged to single {14:00:00Z..16:00:00Z}; free_slots {13:00-14:00Z} and {16:00-17:00Z}, no zero/negative gap between. Interval-merge confirmed. (adjacent events cleaned up)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL78: find_free_slots error shape — summary fallback and no extra API call on the happy path (issue #691) (unit test)

Background: two invariants of the #691 fix (TC-CAL33 covers the headline shape change) that aren't reliably distinguishable live: (1) calendar_summary in the error entry comes from the user's own calendar_cache-backed calendar list, falling back to the bare calendar_id only when that calendar isn't in the list — TC-CAL33's invalid-cal@example.com is never in the list either way, so it can't tell "summary looked up and happened to equal the id" apart from "lookup never ran"; and (2) the summary lookup is gated on at least one calendar actually erroring, so an all-readable find_free_slots call costs exactly one API call (the freebusy query), same as before the fix — not reliably forceable against the live API without mocking the calendar-list call to prove it was (or wasn't) made. Verified by unit test instead.

Checks (unit test) - tests/test_calendar.py::TestFindFreeSlots::test_unreadable_calendar_reports_full_error_shape — a calendar list containing the erroring id's real summary ("Team Cal") produces busy["cal-2"] == [{"calendar_id": "cal-2", "calendar_summary": "Team Cal", "error": "notFound (global)"}] — the summary is genuinely looked up, not just echoing the id. - tests/test_calendar.py::TestFindFreeSlots::test_error_shape_summary_falls_back_to_id_when_calendar_not_in_list — the same failure with the erroring id absent from the calendar list falls back to calendar_summary == "cal-2". - tests/test_calendar.py::TestFindFreeSlots::test_summary_fetch_failure_does_not_break_find_free_slots — a calendar-list fetch that itself raises still returns the error entry (id-fallback summary), proving the best-effort lookup can never turn a working find_free_slots call into a failing one. - tests/test_calendar.py::TestFindFreeSlots::test_all_readable_path_never_fetches_the_calendar_list — with every calendar readable, calendar_cache.get_list and calendarList().list() are both asserted not called — the happy path stays a single API call.

Result (2026-09-10) ✅ PASS — tests/test_calendar.py -k TestFindFreeSlots — 9 passed, including all 4 cited cases. /code-review high origin/develop...HEAD surfaced 2 non-blocking findings, neither exercised by these invariants: summary_by_id's c.get("summary") or c["id"] (this fix) falls back to the id on a falsy or empty-string summary, while list_all_events' equivalent (calendar.py:981) only falls back on a missing key — a calendar with a genuinely empty-string summary would report differently between the two tools, contradicting the docstring's "same shape" claim for that edge case; and the id→summary lookup is duplicated rather than shared between the two tools. Neither affects correctness for a real (non-empty-string) summary. Filed as follow-up #726.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL79: Changing or removing either unchangeable ACL rule returns an actionable error (issue #459) ⚠️ destructive

Background: a secondary calendar's ACL holds two owner rules that no one can change. Any acl().delete() or acl().insert() touching them returns a 403, even a no-op re-grant of owner; both were confirmed live on 2026-10-05. - The primary-owner rule, user:<calendar_id>, returns cannotChangeOwnerAcl ("Cannot change the access level of calendars primary owner."). - The authenticated account's own rule, user:<account email>, returns cannotChangeOwnAcl ("Cannot change your own access level.").

Before #459 both tools returned the raw HttpError string; they now return a message naming the restriction, like remove_calendar_from_list's cannotUnsubscribeFromOwnedCalendar handling (TC-CAL59). Only remove_calendar_acl's primary-owner message points at delete_calendar. An insert that tried to change a grant is never told to delete the calendar (PR #912 QA round 1).

Setup: create a disposable calendar with create_calendar(summary="QA-OwnerAcl"); call it {OWNER_TEST_CAL}. Call list_calendar_acl(calendar_id="{OWNER_TEST_CAL}"). It shows two role: "owner", scope_type: "user" rules: - {PRIMARY_OWNER_RULE_ID}: the one whose scope_value equals {OWNER_TEST_CAL}, so its id is user:{OWNER_TEST_CAL}. - {ACCOUNT_RULE_ID}: the one whose scope_value is the authenticated account. Call that scope_value {ACCOUNT_EMAIL}.

Checks: primary-owner rule (cannotChangeOwnerAcl) - remove_calendar_acl(calendar_id="{OWNER_TEST_CAL}", rule_id="{PRIMARY_OWNER_RULE_ID}") returns {"error": "Google does not allow removing the access rule of a calendar's primary owner (reason: cannotChangeOwnerAcl). Use delete_calendar instead to permanently delete a calendar you own."}, not a raw <HttpError 403 ...> string - add_calendar_acl(calendar_id="{OWNER_TEST_CAL}", role="reader", scope_type="user", scope_value="{OWNER_TEST_CAL}", send_notifications=false) returns {"error": "Google does not allow changing the access level of a calendar's primary owner (reason: cannotChangeOwnerAcl); that rule always stays 'owner'."}, with no mention of delete_calendar

Checks: account's own rule (cannotChangeOwnAcl) - remove_calendar_acl(calendar_id="{OWNER_TEST_CAL}", rule_id="{ACCOUNT_RULE_ID}") returns {"error": "Google does not allow changing or removing your own access rule on a calendar (reason: cannotChangeOwnAcl)."} - add_calendar_acl(calendar_id="{OWNER_TEST_CAL}", role="reader", scope_type="user", scope_value="{ACCOUNT_EMAIL}", send_notifications=false) returns the same cannotChangeOwnAcl message

Checks: unchanged state and raw fallthrough - A follow-up list_calendar_acl(calendar_id="{OWNER_TEST_CAL}") still shows both rules with role: "owner" - A different failure still passes through raw: remove_calendar_acl(calendar_id="{OWNER_TEST_CAL}", rule_id="totally-invalid-rule-id") returns {"error": "<HttpError 400 ...>"} with neither message (same as TC-CAL68)

Cleanup: delete_calendar(calendar_id="{OWNER_TEST_CAL}").

Result (2026-10-05) ❌ FAIL (PR #912 round 1, Kit). The setup's {OWNER_RULE_ID} (the authenticated account's user:<email> rule) isn't the primary owner on a secondary calendar. Removing it, or add_calendar_acl(role="reader") for that email, returns Google's raw 403 cannotChangeOwnAcl ("Cannot change your own access level."), which the PR doesn't map. Both checks pass against the calendar's own-id rule (user:<calendar_id>): each returns the mapped cannotChangeOwnerAcl message, and a later list_calendar_acl still shows that rule as owner. The invalid-rule-id check passes (raw HttpError 400). Cleanup done.

Result (2026-10-05) ✅ PASS (PR #912 round 2, Kit, against 9a85809 after a fresh reconnect). Every check passes against a fresh QA-OwnerAcl calendar, with exactly the strings documented: - Primary-owner rule: remove returns the cannotChangeOwnerAcl message, which names delete_calendar. Add returns the change message, without delete_calendar. - Account's own rule: both remove and add return the cannotChangeOwnAcl message. - A follow-up list_calendar_acl still shows both rules as owner. - An invalid rule id still passes through as raw HttpError 400.

Cleanup done.

Result (2026-10-12) ✅ PASS (PR #1011 round 1, Sky, against 1a8053e after a fresh reconnect). Regression check after the reason check moved to the shared tools/http_errors.py (#916). On a fresh QA-OwnerAcl calendar every check returns exactly the documented string: primary-owner remove (cannotChangeOwnerAcl, names delete_calendar) and add (change message, no delete_calendar); own-rule remove and add (cannotChangeOwnAcl); a follow-up list_calendar_acl still shows both rules as owner; totally-invalid-rule-id passes through as raw HttpError 400. Cleanup done.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL80: list_calendar_acl returns a complete list with no partial marker on a normal calendar (issue #615)

Background: #615 moved list_calendar_acl onto the shared iter_pages helper (tools/pagination.py), with 250 rules per page, a 100-page bound, a {"error": ..., "partial": true} entry when a later page fails or the bound is hit, and the shared response-size cap. This is the live regression check that the normal path is unchanged.

Checks - Call list_calendar_acl(calendar_id="{CALENDAR_ID}") - Returns the same rules as TC-CAL60 (each with id, role, scope_type, scope_value) - No entry has an error or partial key

Result (2026-10-05) ✅ PASS (PR #912 round 1, Kit). The fixture calendar returned its 2 rules (its own-id owner rule and the account's owner rule), each with id/role/scope_type/scope_value, and no error or partial key.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL81: list_calendar_acl partial failure, page bound, and size cap (issue #615) (unit test)

Background: a page-2 failure, a never-ending nextPageToken, and a response over MAX_TOOL_RESPONSE_CHARS can't be forced against the live API (no fixture calendar has more than one page of rules; see TC-CAL76). Verified by unit test instead.

Checks (unit test) - tests/test_calendar.py::TestListCalendarAcl::test_later_page_failure_keeps_earlier_rules_and_flags_partial: page 1's rules are kept, followed by one {"error": ..., "partial": True} entry naming the page count, the rule count, and the API error. - tests/test_calendar.py::TestListCalendarAcl::test_never_ending_page_token_is_bounded_and_flagged_partial: the loop stops at the page bound and flags the list as partial instead of hanging. - tests/test_calendar.py::TestListCalendarAcl::test_oversized_response_raises_size_cap_error: an oversized result raises the shared ValueError naming list_calendar_acl. - tests/test_calendar.py::TestListCalendarAcl::test_requests_the_max_page_size: maxResults=250 is sent. - tests/test_calendar.py::TestListCalendarAcl::test_api_error_returns_error_list: a first-page failure still returns plain [{"error": ...}], with no partial key. - tests/drive/test_files.py::TestListDrives::test_never_ending_page_token_is_bounded: the same helper bounds list_drives.

Result (2026-10-05) ✅ PASS (PR #912 round 1, Kit). Every named unit test passes.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL82: get_event shape unchanged after sharing _shape_event (issue #627)

Background: #627 made get_event call _shape_event (shared with list_events/list_all_events) instead of its own copy, and made the shared helper tolerate explicit null for start/end/organizer/attendees. This is the live regression check on the shape.

Setup: any event in {CALENDAR_ID}, e.g. from list_events(calendar_id="{CALENDAR_ID}"); call its id {EVENT_ID}.

Checks - Call get_event(calendar_id="{CALENDAR_ID}", event_id="{EVENT_ID}") - Returns exactly these keys: id, summary, start, end, location, description, organizer, attendees, recurrence, html_link, status, created, updated - created and updated are RFC 3339 timestamps, not null - start/end/organizer/attendees match the same event's entry in list_events(calendar_id="{CALENDAR_ID}") (fields both tools return)

Result (2026-10-05) ✅ PASS (PR #912 round 1, Kit). get_event on the QA Test Event fixture returned exactly the 13 listed keys, with created/updated as RFC 3339 timestamps. start/end/organizer/attendees match its list_events entry.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL83: _shape_event null guards and collision guard (issue #627) (unit test)

Background: the Calendar API doesn't reliably produce an explicit null for these fields on demand, so the null handling is verified by unit test.

Checks (unit test) - tests/test_calendar.py::TestShapeEvent::test_explicit_nulls_do_not_crash: start/end/organizer set to None shape to None, and attendees: None shapes to [], with no AttributeError. - tests/test_calendar.py::TestShapeEvent::test_extra_key_colliding_with_a_shaped_field_raises: an extra kwarg that collides with a shaped field raises ValueError instead of silently overwriting it. - tests/test_calendar.py::TestGetEvent::test_null_organizer_does_not_crash and tests/test_calendar.py::TestCreateUpdateEventNullTimes: get_event, create_event, and update_event all survive a null start or organizer.

Result (2026-10-05) ✅ PASS (PR #912 round 1, Kit). Every named unit test passes.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL84: list_all_events fan-out cap is process-wide and configurable; calendar summaries match find_free_slots (issues #626, #726) (unit test)

Background: whether two overlapping calls share one concurrency budget isn't observable from a live response, and an empty-string calendar summary can't be set through the Calendar UI. Verified by unit test instead.

Checks (unit test) - tests/test_calendar.py::TestListAllEvents::test_fan_out_cap_is_shared_across_concurrent_invocations: two overlapping list_all_events calls never exceed one cap of in-flight events().list() calls in total. A per-call semaphore fails this test; that was confirmed with a mutation check before the PR was opened. - tests/test_calendar.py::TestListAllEventsConcurrencyEnv: LIST_ALL_EVENTS_MAX_CONCURRENCY defaults to 20, overrides the cap when set, and is floored at 1. - tests/test_calendar.py::TestFindFreeSlots::test_empty_string_summary_matches_list_all_events: a calendar whose summary is "" reports calendar_summary: "" from both tools; only a calendar missing from the user's list falls back to its id.

Result (2026-10-05) ✅ PASS (PR #912 round 1, Kit). Every named unit test passes. Live spot check: list_all_events and find_free_slots over the fixture calendar plus a nonexistent id both report calendar_summary: "mcp-gee-sweet-qa" for the real calendar and fall back to the bare id for the missing one.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


create_event — recurrence support

TC-CAL36: Create a weekly recurring event ⚠️ destructive

Prompt Playwright: required

"Create a recurring event called 'QA-Weekly-Standup' in {CALENDAR_ID} every Monday from 9am to 9:30am Pacific time, starting 2026-07-07"

Checks - Event created successfully - Response includes recurrence field containing an RRULE string with FREQ=WEEKLY and BYDAY=MO - html_link is present - Verify with get_event that the master event's recurrence field is non-null - Follow-up: list_events(expand_recurring=True) shows multiple Monday instances

Result (2026-06-24) ✅ — Event created with id: 3n4mjvi13au7fg1ce0np9ca73k, recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"], status: confirmed, html_link present. Confirmed master ID has no date suffix.

Result (2026-09-04) ✅ PASS create_event recurring weekly -> id dmp5oe7qd342n5odt7a0hqmnhk (no date suffix), recurrence ["RRULE:FREQ=WEEKLY;BYDAY=MO"], status confirmed, html_link present. get_event(master) recurrence non-null. list_events(expand=True) Jul7-28 -> 4 instances (Jul 7 [DTSTART], 13, 20, 27).

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL37: Create a daily recurring event with COUNT limit ⚠️ destructive

Prompt Playwright: required

"Create a recurring event called 'QA-Daily-5x' in {CALENDAR_ID} every day at 8am Pacific for exactly 5 occurrences, starting 2026-07-14"

Checks - Response recurrence contains RRULE:FREQ=DAILY;COUNT=5 - list_events with expand_recurring=True and appropriate time window returns exactly 5 instances - No events appear after the 5th occurrence

Result (2026-06-24) ✅ — Created with recurrence: ["RRULE:FREQ=DAILY;COUNT=5"]. list_events(expand_recurring=True) for Jul 14–19 returned exactly 5 instances (Jul 14–18). No Jul 19+ instances returned. Each instance had recurrence: null.

Result (2026-09-04) ✅ PASS create_event daily COUNT=5 -> recurrence ["RRULE:FREQ=DAILY;COUNT=5"]. list_events(expand=True) Jul14-25 -> exactly 5 instances (Jul 14-18), none after. Each instance recurrence: null.

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL38: Recurrence absent when not provided

Prompt Playwright: required

"Create a one-time event called 'QA-OneOff' in {CALENDAR_ID} on 2026-07-21 from 2pm to 3pm PT"

Checks - Response recurrence is null (not set) - list_events(expand_recurring=False) does not include this event as a recurring series - Confirms non-recurring events are unaffected

Result (2026-06-24) ✅ — Created with recurrence: null. Appears in list_events(expand_recurring=False) with recurrence: null — confirms one-off events are unaffected by recurrence logic.

Result (2026-09-04) ✅ PASS create_event one-off QA-OneOff -> recurrence: null. list_events(expand_recurring=False) shows it with recurrence: null, single entry (not a series).

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


list_events — expand_recurring

TC-CAL39: expand_recurring=False returns master events

Setup: use the recurring event created in TC-CAL36

Prompt Playwright: required

"List events in {CALENDAR_ID} with expand_recurring=False and no time filter"

Checks - Response contains the master recurring event (not individual instances) - Each recurring event has a recurrence field with RRULE strings - The master event id does not contain an underscore + date suffix (instance IDs do) - Non-recurring events also appear (one entry each)

Result (2026-06-24) ✅ — list_events(expand_recurring=False, query="QA-") returned 3 items: QA-Weekly-Standup (recurrence: WEEKLY/MO), QA-Daily-5x (recurrence: DAILY/COUNT=5), QA-OneOff (recurrence: null). All master IDs had no date suffix. Confirms singleEvents=False path works correctly.

Result (2026-09-04) ✅ PASS list_events(expand_recurring=False, query="QA-") -> masters QA-Weekly-Standup (WEEKLY/MO), QA-Daily-5x (DAILY/COUNT=5), QA-OneOff (null), plus non-recurring QA-Timed-Updated & QA-Full-Test. All master IDs suffix-free; no instance IDs present.

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


TC-CAL40: expand_recurring=True (default) expands instances

Setup: use the recurring event created in TC-CAL36

Prompt Playwright: required

"List events in {CALENDAR_ID} between 2026-07-07T00:00:00Z and 2026-07-28T23:59:59Z"

Checks - Response contains individual Monday instances (e.g. 4 Mondays in a 4-week window) - Each instance id contains the base event ID plus a date suffix - recurrence field is null on individual instances (it lives on the master)

Result (2026-06-24) ✅ — Returned 4 instances (Jul 7, 13, 20, 27). Each ID had the _YYYYMMDDTHHMMSSZ suffix. recurrence: null on all instances. Note: Jul 7 is the original start date (Tuesday); the API kept it as the first instance and subsequent occurrences fell on Mondays per BYDAY=MO.

Result (2026-09-04) ✅ PASS list_events(expand_recurring=True default) Jul7-28 for QA-Weekly-Standup -> 4 individual instances, each id ends _YYYYMMDDTHHMMSSZ, recurrence: null on each.

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars). Corrected (2026-10-17, #1044 audit): the Playwright check wasn't run: the fixture-owner browser can't see SA-owned calendars (#886).


update_event — recurrence support

TC-CAL41: Update RRULE on a master event ⚠️ destructive

Setup: use the weekly recurring event from TC-CAL36; retrieve its master event ID via list_events(expand_recurring=False)

Prompt

"Update the recurring event 'QA-Weekly-Standup' in {CALENDAR_ID} — change it from weekly to every 2 weeks"

Checks - Response recurrence reflects the new RRULE (e.g. RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO) - list_events(expand_recurring=True) for a 4-week window now returns 2 instances (not 4) - Other fields (summary, start, end) are unchanged

Result (2026-06-24) ✅ — Patched master with recurrence: ["RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"]. Response echoed the new RRULE. list_events(expand_recurring=True) for Jul 7–28 returned 2 instances (Jul 7 and Jul 20) vs 4 previously. summary and start/end unchanged.

Result (2026-09-04) ✅ PASS update_event(master, recurrence=["RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"]) -> response echoes new RRULE. list_events(expand=True) Jul7-28 -> 2 instances (Jul 7, Jul 20) vs 4 before. summary/start/end unchanged.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars).


TC-CAL42: Remove recurrence by passing an empty list ⚠️ destructive

Setup: use the weekly recurring event from TC-CAL36 (master event ID)

Prompt

"Update the master 'QA-Weekly-Standup' event in {CALENDAR_ID} and remove its recurrence to make it a one-off event — pass an empty recurrence list"

Checks - Response recurrence is null or absent - Event no longer appears as a series in list_events(expand_recurring=False) - Only a single instance exists at the original start date

Result (2026-06-24) ✅ — Patched master with recurrence: []. Response returned recurrence: null. list_events(expand_recurring=False) showed QA-Weekly-Standup with recurrence: null — one entry, no series. Only the Jul 7 occurrence remains.

Result (2026-09-04) ✅ PASS update_event(master, recurrence=[]) -> response recurrence: null. list_events(expand_recurring=False) -> QA-Weekly-Standup single entry, recurrence: null, no series. Only Jul 7 occurrence remains.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars).


TC-CAL43: Update a single instance without affecting the series ⚠️ destructive

Setup: use the recurring event from TC-CAL37; get an instance event ID (contains date suffix) via list_events(expand_recurring=True)

Prompt

"Update just the first occurrence of 'QA-Daily-5x' in {CALENDAR_ID} — rename it 'QA-Daily-5x (Modified)' using its instance event ID"

Checks - That one instance has summary: 'QA-Daily-5x (Modified)' - Other instances still have summary: 'QA-Daily-5x' (unchanged) - The master event summary is unchanged (verify with get_event on the master ID) - Confirms instance-level patching creates an exception override, not a series update

Result (2026-06-24) ✅ — Patched instance 9lrphvbvegq03163gjh7fu35qo_20260714T150000Z with summary: 'QA-Daily-5x (Modified)'. Jul 15–18 instances still returned summary: 'QA-Daily-5x'. get_event on master 9lrphvbvegq03163gjh7fu35qo returned summary: 'QA-Daily-5x' and recurrence: ["RRULE:FREQ=DAILY;COUNT=5"] unchanged. Instance-level exception confirmed.

Result (2026-09-04) ✅ PASS update_event(instance jkk1ai0c..._20260714T150000Z, summary="QA-Daily-5x (Modified)") -> that instance renamed; Jul 15-18 instances still "QA-Daily-5x"; get_event(master jkk1ai0cv78e92jdf3qa6ts130) summary "QA-Daily-5x", recurrence ["RRULE:FREQ=DAILY;COUNT=5"] unchanged. Instance-level exception override confirmed.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass API checks pass; the Playwright step was skipped (the fixture-owner browser can't see SA-owned calendars).


list_all_events

TC-CAL69: Defaults to every subscribed calendar and returns a flat list

Setup: {CALENDAR_ID} and {CALENDAR_ID_2} are both subscribed and each has at least one event in the window below (e.g. TC-CAL20's 'QA-Timed-Test' on 2026-07-01 in {CALENDAR_ID}).

Prompt

Call list_all_events(time_min="2026-07-01T00:00:00Z", time_max="2026-07-02T00:00:00Z") with no calendar_ids.

Checks - Response is a flat list (list), not a dict - 'QA-Timed-Test' appears with calendar_id equal to {CALENDAR_ID} and calendar_summary equal to that calendar's actual summary from list_calendars - Every returned event has both calendar_id and calendar_summary populated - Confirms omitting calendar_ids fans out across the full list_calendars result, not just one calendar

Result (2026-07-30) ⚠️ partial — This worktree's connected account has no mcp-gee-sweet-qa/QA-Timed-Test fixtures set up (real OAuth personal account, no scratch calendar present). Ran with no calendar_ids and a 1-day window instead: response was a flat list, every returned event had both calendar_id and calendar_summary populated correctly, sourced from more than one calendar. The specific 'QA-Timed-Test' fixture assertion could not be checked. Structural behavior confirmed; named-fixture assertion blocked on missing fixtures, not a PR defect.

Result (2026-09-04) ✅ PASS list_all_events no calendar_ids, 2026-07-01..02 -> flat list; QA-Timed-Updated present with calendar_id=SACAL, calendar_summary="mcp-gee-sweet-qa-renamed" (SACAL's real summary); every event has both fields; fanned out across full list_calendars (MINCAL empty in window, correctly absent). (Event is the CAL24/25-renamed 'QA-Timed-Test'.)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL70: Explicit calendar_ids restricts the fan-out

Prompt

Call list_all_events(calendar_ids=["{CALENDAR_ID}"], time_min="2026-07-01T00:00:00Z", time_max="2026-07-02T00:00:00Z").

Checks - Every event in the response has calendar_id: "{CALENDAR_ID}" — no event from {CALENDAR_ID_2} or any other subscribed calendar appears, even if it has events in the same window - Confirms calendar_ids narrows the query instead of always hitting every subscribed calendar

Result (2026-07-30) ✅ — Called with two explicit calendar_ids (real subscribed calendars, no shared/duplicate summary between them) and a narrow window (both empty in that window). Response was an empty flat list — no third calendar's events leaked in. Confirms calendar_ids restricts the fan-out.

Result (2026-09-04) ✅ PASS list_all_events calendar_ids=[SACAL] -> only SACAL's event; QA-MinCal-Event (on MINCAL, same window) absent. Fan-out restricted.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL71: group_by_calendar=True groups by calendar summary

Setup: same fixtures as TC-CAL69 — both calendars have at least one event in the window.

Prompt

Call list_all_events(calendar_ids=["{CALENDAR_ID}", "{CALENDAR_ID_2}"], time_min="2026-07-01T00:00:00Z", time_max="2026-07-02T00:00:00Z", group_by_calendar=true).

Checks - Response is a dict keyed by each calendar's summary (from list_calendars), not a flat list - Each key's value is that calendar's own event list only - Events under each key still carry calendar_id/calendar_summary matching the key they're filed under - If two calendars share an identical summary, both are still present — disambiguated as "{summary} ({calendar_id})" — instead of one silently overwriting the other

Fixed (round 2): grouped was keyed by bare calendar_summary, so two calendars sharing a summary collided and the second overwrote the first with no error. Now keyed via _group_key, which appends " ({calendar_id})" whenever more than one queried calendar shares that summary. No live fixture pair with a duplicate summary was available in this environment to reproduce the collision end-to-end (per round 1's Result below) or reconfirm the fix live — covered by test_group_by_calendar_disambiguates_colliding_summaries in tests/test_calendar.py only. Documented scoping decision, not an oversight.

Result (2026-07-30) ⚠️ passed but confirms a live defect — Ran with two calendars whose real summary values happened to differ, and grouping worked correctly for that case. However, reading the implementation (calendar.py:1001, grouped[key] = ... keyed by calendar_summary) confirms /code-review high's finding: two calendars sharing an identical summary will silently collide — the second overwrites the first in grouped, with the first calendar's entire event list dropped and no error/warning. No live fixture pair with a duplicate summary was available in this environment to reproduce it end-to-end, but the code path is unambiguous. Sending back to Dev — see PR comment.

Result (2026-09-04) ✅ PASS group_by_calendar=true, [SACAL, MINCAL] -> dict keyed by summary ("mcp-gee-sweet-qa-renamed", "mcp-gee-sweet-qa-minimal"); each key holds only that calendar's events; events' calendar_id/summary match their key. Duplicate-summary disambiguation not reproducible (no colliding pair) — covered by unit test, matches prior runs.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL72: One invalid calendar_id does not abort the batch (flat list)

Prompt

Call list_all_events(calendar_ids=["{CALENDAR_ID}", "invalid-cal@example.com"], time_min="2026-07-01T00:00:00Z", time_max="2026-07-02T00:00:00Z").

Checks - The call itself does not raise or return a top-level {"error": ...} — it returns a flat list - Exactly one entry has calendar_id: "invalid-cal@example.com" and an error field (Calendar API's not-found/forbidden message); that entry has no id/summary/event fields - {CALENDAR_ID}'s real events (e.g. 'QA-Timed-Test') are still present in the same list, unaffected by the other calendar's failure - Confirms per-calendar failures are inlined rather than failing the whole call (the asyncio.gather(..., return_exceptions=True) fan-out pattern from #183)

Result (2026-07-30) ✅ — Called with one real calendar_id plus invalid-cal@example.com, narrow window. Response was a flat list with exactly one entry for the invalid ID: {"calendar_id": "invalid-cal@example.com", "calendar_summary": "invalid-cal@example.com", "error": "<HttpError 404 ... notFound ...>"}. No top-level error, no exception. Confirms per-calendar failure inlining.

Result (2026-09-04) ✅ PASS [SACAL, invalid-cal@example.com] flat -> flat list; exactly one entry {calendar_id:"invalid-cal@example.com", calendar_summary:"invalid-cal@example.com", error:""} with no event fields; SACAL event still present; no top-level error.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL73: One invalid calendar_id does not abort the batch (group_by_calendar=True)

Prompt

Call list_all_events(calendar_ids=["{CALENDAR_ID}", "invalid-cal@example.com"], time_min="2026-07-01T00:00:00Z", time_max="2026-07-02T00:00:00Z", group_by_calendar=true).

Checks - result["invalid-cal@example.com"] (falls back to the raw ID as the key since an inaccessible calendar has no known summary) is exactly {"error": "...", "calendar_id": "invalid-cal@example.com"} - The key for {CALENDAR_ID}'s real summary still maps to its normal event list, unaffected by the other calendar's failure

Fixed (round 2): the grouped error entry previously carried only {"error": ...}, recoverable only via the dict key — which per TC-CAL71's collision defect could itself be ambiguous. Now includes calendar_id explicitly, matching the flat-list error shape (TC-CAL72). Covered by test_one_calendar_erroring_in_group_by_calendar_mode in tests/test_calendar.py; round 1's Result below predates this shape and needs re-verification live.

Result (2026-07-30, round 1) — superseded, see round 2 below.

Result (2026-07-30, round 2) ✅ — Re-ran against 481f13d4 after a fresh /mcp reconnect (the first post-fix attempt was caught still serving pre-fix code — see PR comment). result["invalid-cal@example.com"] was {"error": "<HttpError 404 ... notFound ...>", "calendar_id": "invalid-cal@example.com"} — calendar_id now present, confirming the fix live.

Result (2026-09-04) ✅ PASS [SACAL, invalid-cal@example.com] group_by_calendar=true -> result["invalid-cal@example.com"] = {"error":"", "calendar_id":"invalid-cal@example.com"} (calendar_id present — round-2 fix). SACAL summary key -> normal event list, unaffected.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL74: query is forwarded to every calendar in the fan-out

Setup: 'QA-Timed-Test' (TC-CAL20) exists in {CALENDAR_ID}; {CALENDAR_ID_2} has no event matching the query text.

Prompt

Call list_all_events(calendar_ids=["{CALENDAR_ID}", "{CALENDAR_ID_2}"], time_min="2026-01-01T00:00:00Z", time_max="2026-12-31T23:59:59Z", query="QA-Timed-Test").

Checks - 'QA-Timed-Test' is returned from {CALENDAR_ID} - No events are returned from {CALENDAR_ID_2} (confirms query was passed as q to that calendar's events().list() too, not just the first)

Result (2026-07-30) ⏭️ SKIP — No 'QA-Timed-Test' fixture event exists in this worktree's connected account (fixture calendar/event not set up here). Not run; not a PR defect.

Result (2026-09-04) ✅ PASS [SACAL, MINCAL], full-year window, query="QA-Timed-Updated" -> event returned from SACAL only; nothing from MINCAL. Confirms query forwarded as q to every calendar. (Used renamed event name; fixture 'QA-Timed-Test' n/a under SA.)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


TC-CAL75: expand_recurring=False returns master events across every queried calendar

Setup: use the recurring event created in TC-CAL36 (in {CALENDAR_ID}).

Prompt

Call list_all_events(calendar_ids=["{CALENDAR_ID}"], expand_recurring=false, query="QA-").

Checks - 'QA-Weekly-Standup' appears once as its master event (recurrence field populated with RRULE), not expanded into individual instances - Confirms expand_recurring=False is applied per-calendar in the fan-out, matching list_events' own singleEvents/orderBy behavior

Result (2026-07-30) ⏭️ SKIP — TC-CAL36's recurring-event fixture is not present in this worktree's connected account. Not run; not a PR defect.

Result (2026-09-04) ✅ PASS [SACAL], expand_recurring=false, query="QA-" -> QA-Daily-5x once as master (recurrence ["RRULE:FREQ=DAILY;COUNT=5"] populated), not expanded to 5. NOTE: CAL43's instance-override singleton also listed (expected). QA-Weekly-Standup recurrence:null (removed in CAL42). Confirms expand_recurring=False per-calendar in fan-out.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-sa) on SA-owned scratch calendars (TEST_CALENDAR_ID returns 404 to the SA).


Round 2 fixes not covered by a live test case above: - The calendar-list fetch backing summary_by_id (calendar.py, before _fetch_one) had no try/except and could abort the whole batch on a transient failure even when calendar_ids was given explicitly (so the fetch was only needed for display names). Now caught: with explicit calendar_ids, the call proceeds using the bare IDs as display names; with no calendar_ids (so there's no other way to know what to query), it returns a single top-level {"error": ...} instead of raising. Forcing a live calendarList().list() failure isn't something QA can safely trigger against a real account — covered by test_calendar_list_fetch_failure_with_explicit_calendar_ids_does_not_abort and test_calendar_list_fetch_failure_without_calendar_ids_returns_top_level_error in tests/test_calendar.py only. Documented scoping decision, not an oversight. - time_min's default (datetime.now(timezone.utc)) was computed independently inside each concurrent per-calendar task, so calendars in the same call could query against slightly different "now" instants under real contention. Now computed once before the fan-out and reused by every task. Not distinguishable via a single live call's response (there's no observable difference unless an event's start time falls in the microseconds-wide window between two independent datetime.now() calls) — covered by test_default_time_min_is_computed_once_for_the_whole_batch in tests/test_calendar.py only.