gogcli-mcp-calendar 4.5.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -35946,6 +35946,7 @@ async function diagnose(err) {
35946
35946
  const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
35947
35947
  const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
35948
35948
  const hint = isInvalidGrant ? INVALID_GRANT_HINT : isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
35949
+ if (!isAuthError && !isInvalidGrant) return errorResult(`${errText}${hint}`);
35949
35950
  try {
35950
35951
  const accounts = formatAccountList(await run(["auth", "list"]));
35951
35952
  return errorResult(`${errText}
@@ -36043,8 +36044,20 @@ function confirmSpentStore() {
36043
36044
  // ../gogcli-mcp/src/dispatch-confirmation.ts
36044
36045
  var CONFIRM_ACTION_INSTRUCTION = CONFIRM_TOKEN_INSTRUCTION;
36045
36046
  var CONFIRM_FALLBACK_DESCRIPTION = ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai), a two-step flow applies instead: call WITHOUT confirmToken and nothing is sent or changed \u2014 the result has status "confirmation-required", the full preview and a confirmToken. Follow its instruction (by default: show the preview to the user verbatim and only after they explicitly approve it in chat, call again with the SAME arguments plus confirmToken). The tool re-reads what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) if it changed; TOKEN_EXPIRED / TOKEN_REUSED / TOKEN_INVALID also do nothing. A server set to MCP_CONFIRM_MODE=refuse refuses instead.';
36047
+ function refusedInRun(what, via, does, instead) {
36048
+ return `${what} ${does} and is not available through ${via}. ${instead}`;
36049
+ }
36046
36050
  function gatedElsewhere(what, via, does, tool) {
36047
- return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
36051
+ return refusedInRun(what, via, does, `Use ${tool}, which asks the user to confirm.`);
36052
+ }
36053
+ function flagValue(args, name) {
36054
+ const flag = `--${name}`;
36055
+ for (let i = 0; i < args.length; i++) {
36056
+ const a = args[i];
36057
+ if (a === flag) return args[i + 1] ?? "";
36058
+ if (a.startsWith(`${flag}=`)) return a.slice(flag.length + 1);
36059
+ }
36060
+ return void 0;
36048
36061
  }
36049
36062
  async function requireDispatchConfirmation(ctx, options) {
36050
36063
  const { action, fallback } = options;
@@ -36206,21 +36219,47 @@ function authToolsFor(defaultServices) {
36206
36219
  }
36207
36220
 
36208
36221
  // ../gogcli-mcp/src/tools/calendar.ts
36209
- var CALENDAR_GATED = {
36210
- create: "gog_calendar_create",
36211
- add: "gog_calendar_create",
36212
- new: "gog_calendar_create",
36213
- update: "gog_calendar_update",
36214
- edit: "gog_calendar_update",
36215
- set: "gog_calendar_update",
36216
- respond: "gog_calendar_respond",
36217
- rsvp: "gog_calendar_respond",
36218
- reply: "gog_calendar_respond"
36222
+ var ASKS = (tool) => `Use ${tool}, which asks the user to confirm.`;
36223
+ var CALENDAR_REFUSED = {
36224
+ create: { does: "can change what guests see", instead: ASKS("gog_calendar_create") },
36225
+ add: { does: "can change what guests see", instead: ASKS("gog_calendar_create") },
36226
+ new: { does: "can change what guests see", instead: ASKS("gog_calendar_create") },
36227
+ update: { does: "can change what guests see", instead: ASKS("gog_calendar_update") },
36228
+ edit: { does: "can change what guests see", instead: ASKS("gog_calendar_update") },
36229
+ set: { does: "can change what guests see", instead: ASKS("gog_calendar_update") },
36230
+ respond: { does: "can change what guests see", instead: ASKS("gog_calendar_respond") },
36231
+ rsvp: { does: "can change what guests see", instead: ASKS("gog_calendar_respond") },
36232
+ reply: { does: "can change what guests see", instead: ASKS("gog_calendar_respond") },
36233
+ move: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS("gog_calendar_move") },
36234
+ transfer: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS("gog_calendar_move") },
36235
+ delete: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
36236
+ del: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
36237
+ rm: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
36238
+ remove: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
36239
+ "delete-calendar": { does: "deletes a calendar and every event on it", instead: ASKS("gog_calendar_delete_calendar") },
36240
+ "out-of-office": { does: "auto-declines invitations and notifies their organizers", instead: ASKS("gog_calendar_out_of_office") },
36241
+ ooo: { does: "auto-declines invitations and notifies their organizers", instead: ASKS("gog_calendar_out_of_office") }
36219
36242
  };
36220
- function vetCalendarRun(subcommand, _args) {
36243
+ function vetCalendarRun(subcommand, args) {
36221
36244
  const sub = subcommand.toLowerCase();
36222
- const tool = Object.hasOwn(CALENDAR_GATED, sub) ? CALENDAR_GATED[sub] : void 0;
36223
- return tool ? gatedElsewhere(`gog calendar ${sub}`, "gog_calendar_run", "can change what guests see", tool) : void 0;
36245
+ const what = `gog calendar ${sub}`;
36246
+ const via = "gog_calendar_run";
36247
+ if (Object.hasOwn(CALENDAR_REFUSED, sub)) {
36248
+ const { does, instead } = CALENDAR_REFUSED[sub];
36249
+ return refusedInRun(what, via, does, instead);
36250
+ }
36251
+ if (sub === "propose-time" && (flagValue(args, "decline") !== void 0 || flagValue(args, "comment") !== void 0)) {
36252
+ return gatedElsewhere(`${what} --decline`, via, "declines the event and notifies the organizer", "gog_calendar_respond");
36253
+ }
36254
+ if ((sub === "focus-time" || sub === "focus") && flagValue(args, "auto-decline")?.toLowerCase() !== "none") {
36255
+ return refusedInRun(
36256
+ what,
36257
+ via,
36258
+ "auto-declines invitations and notifies their organizers (gog defaults --auto-decline to all)",
36259
+ "Pass --auto-decline=none, or ask the user to create it from Google Calendar."
36260
+ );
36261
+ }
36262
+ return void 0;
36224
36263
  }
36225
36264
  function parseAttendees(value) {
36226
36265
  if (!value) return [];
@@ -36245,6 +36284,8 @@ function eventSnapshot(raw) {
36245
36284
  organizer: str(event?.organizer?.email),
36246
36285
  // Guests are the OTHER people: not this account's own attendee row, not rooms.
36247
36286
  guests: (Array.isArray(event?.attendees) ? event.attendees : []).filter((a) => a.self !== true && a.resource !== true && typeof a.email === "string").map((a) => a.email),
36287
+ // A series, or one instance of one: gog's delete --scope defaults to all.
36288
+ ...Array.isArray(event?.recurrence) && event.recurrence.length > 0 || typeof event?.recurringEventId === "string" ? { recurring: true } : {},
36248
36289
  etag: str(event?.etag)
36249
36290
  };
36250
36291
  }
@@ -36466,14 +36507,35 @@ function registerCalendarTools(server) {
36466
36507
  return runOrDiagnose(args, { account });
36467
36508
  });
36468
36509
  server.registerTool("gog_calendar_delete", {
36469
- description: "Delete a calendar event.",
36510
+ description: "Delete a calendar event. It disappears from every guest's calendar too, and for a recurring event gog deletes the WHOLE series, so when the event has guests or recurs this reads it and asks the MCP host to show the user a confirmation prompt with the event as it stands; a guest-free, one-off event is deleted without asking." + CONFIRM_FALLBACK_DESCRIPTION,
36470
36511
  annotations: { destructiveHint: true },
36471
36512
  inputSchema: external_exports.object({
36472
36513
  calendarId: external_exports.string().describe("Calendar ID"),
36473
36514
  eventId: external_exports.string().describe("Event ID"),
36474
- account: accountParam
36515
+ account: accountParam,
36516
+ confirmToken: confirmTokenParam
36475
36517
  })
36476
- }, async ({ calendarId, eventId, account }) => {
36518
+ }, async ({ calendarId, eventId, account, confirmToken }, ctx) => {
36519
+ const read = await readEvent(calendarId, eventId, account);
36520
+ if (read.error) return read.error;
36521
+ const { etag, ...event } = read.event;
36522
+ if (event.guests.length > 0 || event.recurring) {
36523
+ const view = { calendarId, eventId, event, scope: event.recurring ? "the whole recurring series" : "this event" };
36524
+ const confirmation = await requireDispatchConfirmation(ctx, {
36525
+ action: "calendar.delete",
36526
+ message: event.recurring ? "Review and confirm deleting this ENTIRE recurring series:" : "Review and confirm deleting this event from every guest's calendar:",
36527
+ confirmationLabel: "Confirm that this event should be deleted now.",
36528
+ details: view,
36529
+ unsupportedNote: "Ask the user to delete it from Google Calendar.",
36530
+ fallback: {
36531
+ tool: "gog_calendar_delete",
36532
+ account,
36533
+ confirmToken,
36534
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view })
36535
+ }
36536
+ });
36537
+ if (confirmation) return confirmation;
36538
+ }
36477
36539
  return runOrDiagnose(["calendar", "delete", pos(calendarId), pos(eventId), "--force"], { account });
36478
36540
  });
36479
36541
  server.registerTool("gog_calendar_respond", {
@@ -36566,7 +36628,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
36566
36628
  );
36567
36629
 
36568
36630
  // ../gogcli-mcp/src/server.ts
36569
- var VERSION = true ? "4.5.0" : "0.0.0";
36631
+ var VERSION = true ? "4.5.1" : "0.0.0";
36570
36632
 
36571
36633
  // ../gogcli-mcp/src/bootstrap-auth.ts
36572
36634
  import { join as join5 } from "node:path";
@@ -36642,6 +36704,20 @@ var MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
36642
36704
 
36643
36705
  // src/tools/calendar-extra.ts
36644
36706
  var meetAccess = external_exports.enum(["open", "trusted", "restricted"]);
36707
+ function calendarSnapshot(raw, calendarId) {
36708
+ let cal;
36709
+ try {
36710
+ const parsed = JSON.parse(raw);
36711
+ cal = parsed?.result ?? parsed ?? void 0;
36712
+ } catch {
36713
+ cal = void 0;
36714
+ }
36715
+ return {
36716
+ id: calendarId,
36717
+ ...typeof cal?.summary === "string" ? { summary: cal.summary } : {},
36718
+ ...typeof cal?.etag === "string" ? { etag: cal.etag } : {}
36719
+ };
36720
+ }
36645
36721
  function registerExtraCalendarTools(server) {
36646
36722
  server.registerTool("gog_meet_create", {
36647
36723
  description: "Create a Google Meet space and return its meeting code.",
@@ -36856,34 +36932,56 @@ function registerExtraCalendarTools(server) {
36856
36932
  return runOrDiagnose(args, { account });
36857
36933
  });
36858
36934
  server.registerTool("gog_calendar_move", {
36859
- description: "Move an event from one calendar to another; the destination calendar becomes the organizer.",
36935
+ description: "Move an event from one calendar to another; the destination calendar becomes the organizer. With sendUpdates all or externalOnly Google emails the guests, so this reads the event and asks the MCP host to show the user a confirmation prompt with the event, its guests and the destination first; a move that notifies nobody (the default) is made without asking." + CONFIRM_FALLBACK_DESCRIPTION,
36860
36936
  annotations: { destructiveHint: false },
36861
36937
  inputSchema: external_exports.object({
36862
36938
  calendarId: external_exports.string().describe("Source calendar ID"),
36863
36939
  eventId: external_exports.string().describe("Event ID"),
36864
36940
  destinationCalendarId: external_exports.string().describe("Destination calendar ID that becomes the event organizer"),
36865
- sendUpdates: external_exports.enum(["all", "externalOnly", "none"]).optional().describe("Notification mode (default: none)"),
36866
- account: accountParam
36941
+ sendUpdates: external_exports.enum(["all", "externalOnly", "none"]).optional().describe("Notification mode (default: none). all / externalOnly email the guests and ask the user first."),
36942
+ account: accountParam,
36943
+ confirmToken: confirmTokenParam
36867
36944
  })
36868
- }, async ({ calendarId, eventId, destinationCalendarId, sendUpdates, account }) => {
36945
+ }, async ({ calendarId, eventId, destinationCalendarId, sendUpdates, account, confirmToken }, ctx) => {
36869
36946
  const args = ["calendar", "move", pos(calendarId), pos(eventId), pos(destinationCalendarId)];
36870
36947
  if (sendUpdates) args.push(`--send-updates=${sendUpdates}`);
36948
+ if (sendUpdates === "all" || sendUpdates === "externalOnly") {
36949
+ const got = await runOrDiagnose(["calendar", "event", pos(calendarId), pos(eventId)], { account });
36950
+ if (got.isError) return got;
36951
+ const { etag, ...event } = eventSnapshot(resultText(got));
36952
+ const view = { calendarId, eventId, event, destinationCalendarId, emails: sendUpdates === "all" ? "every guest" : "guests outside your domain" };
36953
+ const confirmation = await requireDispatchConfirmation(ctx, {
36954
+ action: "calendar.move",
36955
+ message: "Review and confirm moving this event \u2014 Google will email the guests:",
36956
+ confirmationLabel: "Confirm that this event should be moved and the guests notified now.",
36957
+ details: view,
36958
+ unsupportedNote: "Move it with sendUpdates none instead, which notifies nobody.",
36959
+ fallback: {
36960
+ tool: "gog_calendar_move",
36961
+ account,
36962
+ confirmToken,
36963
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view })
36964
+ }
36965
+ });
36966
+ if (confirmation) return confirmation;
36967
+ }
36871
36968
  return runOrDiagnose(args, { account });
36872
36969
  });
36873
36970
  server.registerTool("gog_calendar_out_of_office", {
36874
- description: "Create an Out of Office event that auto-declines invitations during the block.",
36971
+ description: "Create an Out of Office event that auto-declines invitations during the block. gog auto-declines EVERY conflicting meeting by default (autoDecline all), and each organizer is notified, so unless autoDecline is none this asks the MCP host to show the user a confirmation prompt with the window, the decline mode and the message first; nothing is created unless they accept." + CONFIRM_FALLBACK_DESCRIPTION,
36875
36972
  annotations: { destructiveHint: true },
36876
36973
  inputSchema: external_exports.object({
36877
36974
  from: external_exports.string().describe("Start date or datetime (RFC3339 or YYYY-MM-DD)"),
36878
36975
  to: external_exports.string().describe("End date or datetime (RFC3339 or YYYY-MM-DD)"),
36879
36976
  calendarId: external_exports.string().optional().describe("Calendar ID (default: primary)"),
36880
36977
  summary: external_exports.string().optional().describe('Out of office title (default: "Out of office")'),
36881
- autoDecline: external_exports.enum(["none", "all", "new"]).optional().describe("Auto-decline mode (default: all)"),
36978
+ autoDecline: external_exports.enum(["none", "all", "new"]).optional().describe("Auto-decline mode (default: all \u2014 declines existing AND new conflicting meetings; none notifies nobody and does not ask)"),
36882
36979
  declineMessage: external_exports.string().optional().describe("Message for declined invitations"),
36883
36980
  allDay: external_exports.boolean().optional().describe("Create as an all-day event"),
36884
- account: accountParam
36981
+ account: accountParam,
36982
+ confirmToken: confirmTokenParam
36885
36983
  })
36886
- }, async ({ from, to, calendarId, summary, autoDecline, declineMessage, allDay, account }) => {
36984
+ }, async ({ from, to, calendarId, summary, autoDecline, declineMessage, allDay, account, confirmToken }, ctx) => {
36887
36985
  const args = ["calendar", "out-of-office"];
36888
36986
  if (calendarId) args.push(pos(calendarId));
36889
36987
  args.push(`--from=${from}`);
@@ -36892,6 +36990,32 @@ function registerExtraCalendarTools(server) {
36892
36990
  if (autoDecline) args.push(`--auto-decline=${autoDecline}`);
36893
36991
  if (declineMessage) args.push(`--decline-message=${declineMessage}`);
36894
36992
  if (allDay) args.push("--all-day");
36993
+ if (autoDecline !== "none") {
36994
+ const mode = autoDecline ?? "all";
36995
+ const view = {
36996
+ calendarId: calendarId ?? "primary",
36997
+ from,
36998
+ to,
36999
+ allDay: Boolean(allDay),
37000
+ summary: summary ?? "Out of office",
37001
+ declines: mode === "all" ? "every existing and new conflicting meeting" : "new conflicting invitations",
37002
+ declineMessage
37003
+ };
37004
+ const confirmation = await requireDispatchConfirmation(ctx, {
37005
+ action: "calendar.out-of-office",
37006
+ message: "Review and confirm this Out of Office block \u2014 conflicting meetings are declined and their organizers notified:",
37007
+ confirmationLabel: "Confirm that meetings in this window should be auto-declined.",
37008
+ details: view,
37009
+ unsupportedNote: "Create it with autoDecline none instead; the user can turn on auto-decline in Google Calendar.",
37010
+ fallback: {
37011
+ tool: "gog_calendar_out_of_office",
37012
+ account,
37013
+ confirmToken,
37014
+ subject: () => ({ target: view.calendarId, payload: view, preview: view })
37015
+ }
37016
+ });
37017
+ if (confirmation) return confirmation;
37018
+ }
36895
37019
  return runOrDiagnose(args, { account });
36896
37020
  });
36897
37021
  server.registerTool("gog_calendar_unsubscribe", {
@@ -36905,13 +37029,35 @@ function registerExtraCalendarTools(server) {
36905
37029
  return runOrDiagnose(["calendar", "unsubscribe", pos(calendarId)], { account });
36906
37030
  });
36907
37031
  server.registerTool("gog_calendar_delete_calendar", {
36908
- description: "Permanently delete an owned secondary calendar and all its events. Cannot delete your primary calendar. To merely remove a calendar you do not own from your list, use gog_calendar_unsubscribe.",
37032
+ description: "Permanently delete an owned secondary calendar and all its events. Cannot delete your primary calendar. To merely remove a calendar you do not own from your list, use gog_calendar_unsubscribe. Reads the calendar and asks the MCP host to show the user a confirmation prompt naming it first; nothing is deleted unless they accept." + CONFIRM_FALLBACK_DESCRIPTION,
36909
37033
  annotations: { destructiveHint: true },
36910
37034
  inputSchema: external_exports.object({
36911
37035
  calendarId: external_exports.string().describe("Owned secondary calendar ID or alias"),
36912
- account: accountParam
37036
+ account: accountParam,
37037
+ confirmToken: confirmTokenParam
36913
37038
  })
36914
- }, async ({ calendarId, account }) => {
37039
+ }, async ({ calendarId, account, confirmToken }, ctx) => {
37040
+ const got = await runOrDiagnose(
37041
+ ["api", "call", "calendar", "v3", "calendars.get", `--params=${JSON.stringify({ calendarId })}`],
37042
+ { account }
37043
+ );
37044
+ if (got.isError) return got;
37045
+ const { etag, ...calendar } = calendarSnapshot(resultText(got), calendarId);
37046
+ const view = { calendar, deletes: "the calendar and every event on it, for good" };
37047
+ const confirmation = await requireDispatchConfirmation(ctx, {
37048
+ action: "calendar.delete-calendar",
37049
+ message: "Review and confirm PERMANENTLY deleting this calendar and all of its events:",
37050
+ confirmationLabel: "Confirm that this calendar should be deleted now.",
37051
+ details: view,
37052
+ unsupportedNote: "Ask the user to delete it from Google Calendar's settings, or use gog_calendar_unsubscribe to only hide it.",
37053
+ fallback: {
37054
+ tool: "gog_calendar_delete_calendar",
37055
+ account,
37056
+ confirmToken,
37057
+ subject: () => ({ target: calendarId, revision: etag, payload: view, preview: view })
37058
+ }
37059
+ });
37060
+ if (confirmation) return confirmation;
36915
37061
  return runOrDiagnose(["calendar", "delete-calendar", pos(calendarId), "--force"], { account });
36916
37062
  });
36917
37063
  server.registerTool("gog_meet_participants", {
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp-calendar",
5
5
  "display_name": "gogcli (Calendar)",
6
- "version": "4.5.0",
6
+ "version": "4.5.1",
7
7
  "description": "Extended Google Calendar for Claude via gogcli — auth + Calendar events + Meet space management",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -111,7 +111,7 @@
111
111
  },
112
112
  {
113
113
  "name": "gog_calendar_delete",
114
- "description": "Delete a calendar event"
114
+ "description": "Delete a calendar event (asks the user to confirm when it has guests or recurs)"
115
115
  },
116
116
  {
117
117
  "name": "gog_calendar_respond",
@@ -147,11 +147,11 @@
147
147
  },
148
148
  {
149
149
  "name": "gog_calendar_move",
150
- "description": "Move an event to another calendar"
150
+ "description": "Move an event to another calendar (asks the user to confirm when guests are emailed)"
151
151
  },
152
152
  {
153
153
  "name": "gog_calendar_out_of_office",
154
- "description": "Create an Out of Office event that auto-declines invitations"
154
+ "description": "Create an Out of Office event that auto-declines invitations (asks the user to confirm unless autoDecline is none)"
155
155
  },
156
156
  {
157
157
  "name": "gog_calendar_unsubscribe",
@@ -159,7 +159,7 @@
159
159
  },
160
160
  {
161
161
  "name": "gog_calendar_delete_calendar",
162
- "description": "Delete an owned secondary calendar"
162
+ "description": "Delete an owned secondary calendar (asks the user to confirm)"
163
163
  },
164
164
  {
165
165
  "name": "gog_meet_create",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-calendar",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-calendar",
5
5
  "description": "Extended Google Calendar + Meet MCP server via gogcli — auth + Calendar events + Meet space management",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -1,10 +1,38 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, pageTokenParam, pageAliasParam, resolvePageToken, pos} from '../../../gogcli-mcp/src/lib.js';
3
+ import {
4
+ accountParam,
5
+ runOrDiagnose,
6
+ pageTokenParam,
7
+ pageAliasParam,
8
+ resolvePageToken,
9
+ pos,
10
+ CONFIRM_FALLBACK_DESCRIPTION,
11
+ confirmTokenParam,
12
+ eventSnapshot,
13
+ requireDispatchConfirmation,
14
+ resultText,
15
+ } from '../../../gogcli-mcp/src/lib.js';
4
16
  import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
5
17
 
6
18
  const meetAccess = z.enum(['open', 'trusted', 'restricted']);
7
19
 
20
+ /** A calendar by name, for a delete's prompt; `etag` apart as the token's revision. Unreadable output names nothing. */
21
+ export function calendarSnapshot(raw: string, calendarId: string): { id: string; summary?: string; etag?: string } {
22
+ let cal: { summary?: unknown; etag?: unknown } | undefined;
23
+ try {
24
+ const parsed = JSON.parse(raw) as ({ result?: typeof cal } & NonNullable<typeof cal>) | null;
25
+ cal = parsed?.result ?? parsed ?? undefined;
26
+ } catch {
27
+ cal = undefined;
28
+ }
29
+ return {
30
+ id: calendarId,
31
+ ...(typeof cal?.summary === 'string' ? { summary: cal.summary } : {}),
32
+ ...(typeof cal?.etag === 'string' ? { etag: cal.etag } : {}),
33
+ };
34
+ }
35
+
8
36
  // Meet spaces are the conferencing surface attached to calendar events,
9
37
  // so they live in the calendar sub-package.
10
38
  export function registerExtraCalendarTools(server: McpServer): void {
@@ -241,35 +269,63 @@ export function registerExtraCalendarTools(server: McpServer): void {
241
269
  });
242
270
 
243
271
  server.registerTool('gog_calendar_move', {
244
- description: 'Move an event from one calendar to another; the destination calendar becomes the organizer.',
272
+ description: 'Move an event from one calendar to another; the destination calendar becomes the organizer. With '
273
+ + 'sendUpdates all or externalOnly Google emails the guests, so this reads the event and asks the MCP host to '
274
+ + 'show the user a confirmation prompt with the event, its guests and the destination first; a move that '
275
+ + 'notifies nobody (the default) is made without asking.' + CONFIRM_FALLBACK_DESCRIPTION,
245
276
  annotations: { destructiveHint: false },
246
277
  inputSchema: z.object({
247
278
  calendarId: z.string().describe('Source calendar ID'),
248
279
  eventId: z.string().describe('Event ID'),
249
280
  destinationCalendarId: z.string().describe('Destination calendar ID that becomes the event organizer'),
250
- sendUpdates: z.enum(['all', 'externalOnly', 'none']).optional().describe('Notification mode (default: none)'),
281
+ sendUpdates: z.enum(['all', 'externalOnly', 'none']).optional().describe('Notification mode (default: none). all / externalOnly email the guests and ask the user first.'),
251
282
  account: accountParam,
283
+ confirmToken: confirmTokenParam,
252
284
  }),
253
- }, async ({ calendarId, eventId, destinationCalendarId, sendUpdates, account }) => {
285
+ }, async ({ calendarId, eventId, destinationCalendarId, sendUpdates, account, confirmToken }, ctx) => {
254
286
  const args: GogArg[] = ['calendar', 'move', pos(calendarId), pos(eventId), pos(destinationCalendarId)];
255
287
  if (sendUpdates) args.push(`--send-updates=${sendUpdates}`);
288
+ if (sendUpdates === 'all' || sendUpdates === 'externalOnly') {
289
+ const got = await runOrDiagnose(['calendar', 'event', pos(calendarId), pos(eventId)], { account });
290
+ if (got.isError) return got;
291
+ const { etag, ...event } = eventSnapshot(resultText(got));
292
+ const view = { calendarId, eventId, event, destinationCalendarId, emails: sendUpdates === 'all' ? 'every guest' : 'guests outside your domain' };
293
+ const confirmation = await requireDispatchConfirmation(ctx, {
294
+ action: 'calendar.move',
295
+ message: 'Review and confirm moving this event — Google will email the guests:',
296
+ confirmationLabel: 'Confirm that this event should be moved and the guests notified now.',
297
+ details: view,
298
+ unsupportedNote: 'Move it with sendUpdates none instead, which notifies nobody.',
299
+ fallback: {
300
+ tool: 'gog_calendar_move',
301
+ account,
302
+ confirmToken,
303
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view }),
304
+ },
305
+ });
306
+ if (confirmation) return confirmation;
307
+ }
256
308
  return runOrDiagnose(args, { account });
257
309
  });
258
310
 
259
311
  server.registerTool('gog_calendar_out_of_office', {
260
- description: 'Create an Out of Office event that auto-declines invitations during the block.',
312
+ description: 'Create an Out of Office event that auto-declines invitations during the block. gog auto-declines '
313
+ + 'EVERY conflicting meeting by default (autoDecline all), and each organizer is notified, so unless autoDecline '
314
+ + 'is none this asks the MCP host to show the user a confirmation prompt with the window, the decline mode and '
315
+ + 'the message first; nothing is created unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION,
261
316
  annotations: { destructiveHint: true },
262
317
  inputSchema: z.object({
263
318
  from: z.string().describe('Start date or datetime (RFC3339 or YYYY-MM-DD)'),
264
319
  to: z.string().describe('End date or datetime (RFC3339 or YYYY-MM-DD)'),
265
320
  calendarId: z.string().optional().describe('Calendar ID (default: primary)'),
266
321
  summary: z.string().optional().describe('Out of office title (default: "Out of office")'),
267
- autoDecline: z.enum(['none', 'all', 'new']).optional().describe('Auto-decline mode (default: all)'),
322
+ autoDecline: z.enum(['none', 'all', 'new']).optional().describe('Auto-decline mode (default: all — declines existing AND new conflicting meetings; none notifies nobody and does not ask)'),
268
323
  declineMessage: z.string().optional().describe('Message for declined invitations'),
269
324
  allDay: z.boolean().optional().describe('Create as an all-day event'),
270
325
  account: accountParam,
326
+ confirmToken: confirmTokenParam,
271
327
  }),
272
- }, async ({ from, to, calendarId, summary, autoDecline, declineMessage, allDay, account }) => {
328
+ }, async ({ from, to, calendarId, summary, autoDecline, declineMessage, allDay, account, confirmToken }, ctx) => {
273
329
  const args: GogArg[] = ['calendar', 'out-of-office'];
274
330
  if (calendarId) args.push(pos(calendarId));
275
331
  args.push(`--from=${from}`);
@@ -278,6 +334,32 @@ export function registerExtraCalendarTools(server: McpServer): void {
278
334
  if (autoDecline) args.push(`--auto-decline=${autoDecline}`);
279
335
  if (declineMessage) args.push(`--decline-message=${declineMessage}`);
280
336
  if (allDay) args.push('--all-day');
337
+ if (autoDecline !== 'none') {
338
+ const mode = autoDecline ?? 'all';
339
+ const view = {
340
+ calendarId: calendarId ?? 'primary',
341
+ from,
342
+ to,
343
+ allDay: Boolean(allDay),
344
+ summary: summary ?? 'Out of office',
345
+ declines: mode === 'all' ? 'every existing and new conflicting meeting' : 'new conflicting invitations',
346
+ declineMessage,
347
+ };
348
+ const confirmation = await requireDispatchConfirmation(ctx, {
349
+ action: 'calendar.out-of-office',
350
+ message: 'Review and confirm this Out of Office block — conflicting meetings are declined and their organizers notified:',
351
+ confirmationLabel: 'Confirm that meetings in this window should be auto-declined.',
352
+ details: view,
353
+ unsupportedNote: 'Create it with autoDecline none instead; the user can turn on auto-decline in Google Calendar.',
354
+ fallback: {
355
+ tool: 'gog_calendar_out_of_office',
356
+ account,
357
+ confirmToken,
358
+ subject: () => ({ target: view.calendarId, payload: view, preview: view }),
359
+ },
360
+ });
361
+ if (confirmation) return confirmation;
362
+ }
281
363
  return runOrDiagnose(args, { account });
282
364
  });
283
365
 
@@ -293,13 +375,36 @@ export function registerExtraCalendarTools(server: McpServer): void {
293
375
  });
294
376
 
295
377
  server.registerTool('gog_calendar_delete_calendar', {
296
- description: 'Permanently delete an owned secondary calendar and all its events. Cannot delete your primary calendar. To merely remove a calendar you do not own from your list, use gog_calendar_unsubscribe.',
378
+ description: 'Permanently delete an owned secondary calendar and all its events. Cannot delete your primary calendar. '
379
+ + 'To merely remove a calendar you do not own from your list, use gog_calendar_unsubscribe. Reads the calendar and '
380
+ + 'asks the MCP host to show the user a confirmation prompt naming it first; nothing is deleted unless they accept.'
381
+ + CONFIRM_FALLBACK_DESCRIPTION,
297
382
  annotations: { destructiveHint: true },
298
383
  inputSchema: z.object({
299
384
  calendarId: z.string().describe('Owned secondary calendar ID or alias'),
300
385
  account: accountParam,
386
+ confirmToken: confirmTokenParam,
301
387
  }),
302
- }, async ({ calendarId, account }) => {
388
+ }, async ({ calendarId, account, confirmToken }, ctx) => {
389
+ const got = await runOrDiagnose(
390
+ ['api', 'call', 'calendar', 'v3', 'calendars.get', `--params=${JSON.stringify({ calendarId })}`], { account });
391
+ if (got.isError) return got;
392
+ const { etag, ...calendar } = calendarSnapshot(resultText(got), calendarId);
393
+ const view = { calendar, deletes: 'the calendar and every event on it, for good' };
394
+ const confirmation = await requireDispatchConfirmation(ctx, {
395
+ action: 'calendar.delete-calendar',
396
+ message: 'Review and confirm PERMANENTLY deleting this calendar and all of its events:',
397
+ confirmationLabel: 'Confirm that this calendar should be deleted now.',
398
+ details: view,
399
+ unsupportedNote: 'Ask the user to delete it from Google Calendar\'s settings, or use gog_calendar_unsubscribe to only hide it.',
400
+ fallback: {
401
+ tool: 'gog_calendar_delete_calendar',
402
+ account,
403
+ confirmToken,
404
+ subject: () => ({ target: calendarId, revision: etag, payload: view, preview: view }),
405
+ },
406
+ });
407
+ if (confirmation) return confirmation;
303
408
  return runOrDiagnose(['calendar', 'delete-calendar', pos(calendarId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
304
409
  });
305
410
 
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { registerExtraCalendarTools, calendarSnapshot } from '../../src/tools/calendar-extra.js';
3
+ import * as lib from '../../../gogcli-mcp/src/lib.js';
4
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { rawTextResult } from '@chrischall/mcp-utils';
6
+ import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
7
+ import { pos } from '../../../gogcli-mcp/src/argv.js';
8
+ import { resetConfirmTokenState } from '../../../gogcli-mcp/src/send-confirm-token.js';
9
+
10
+ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof lib>();
12
+ return { ...actual, runOrDiagnose: vi.fn() };
13
+ });
14
+
15
+ // ============================================================================
16
+ // Fleet audit 2026-09-24 SEC-6: a move that emails the guests, an Out of Office
17
+ // block that auto-declines (and notifies) every conflicting organizer, and a
18
+ // calendar delete all ask first — prompted, refused, and via the token fallback.
19
+ // ============================================================================
20
+
21
+ const ORIGINAL_ENV = { ...process.env };
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ process.env = { ...ORIGINAL_ENV };
25
+ delete process.env.MCP_CONFIRM_MODE;
26
+ process.env.GOG_ACCOUNT = 'me@example.com';
27
+ resetConfirmTokenState();
28
+ });
29
+ afterEach(() => { process.env = ORIGINAL_ENV; });
30
+
31
+ const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
32
+
33
+ async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
34
+ const seen: ElicitRequest[] = [];
35
+ const harness = await createTestHarness(registerExtraCalendarTools, { elicitation: async (r) => { seen.push(r); return answer; } });
36
+ const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
37
+ return { harness, seen, details };
38
+ }
39
+ const unprompted = () => createTestHarness(registerExtraCalendarTools);
40
+
41
+ /** Answer reads keyed by their command words; a `failing` prefix returns an error result. */
42
+ function stub(reads: Record<string, string>, failing?: string) {
43
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
44
+ const key = args.filter((a): a is string => typeof a === 'string' && !a.startsWith('--')).join(' ');
45
+ if (failing && key.startsWith(failing)) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
46
+ for (const [prefix, out] of Object.entries(reads)) if (key.startsWith(prefix)) return rawTextResult(out);
47
+ return rawTextResult('{"ok":true}');
48
+ });
49
+ }
50
+ const callsTo = (...words: string[]) => vi.mocked(lib.runOrDiagnose).mock.calls.filter(([args]) =>
51
+ words.every((w, i) => args[i] === w));
52
+
53
+ describe('gog_calendar_move', () => {
54
+ const EVENT = (etag = '"v1"') => JSON.stringify({ event: {
55
+ id: 'e1', etag, summary: 'Budget review', start: { dateTime: '2026-09-30T14:00:00-04:00' },
56
+ end: { dateTime: '2026-09-30T15:00:00-04:00' }, organizer: { email: 'me@example.com' },
57
+ attendees: [{ email: 'me@example.com', self: true }, { email: 'boss@example.com' }],
58
+ } });
59
+ const ARGS = { calendarId: 'primary', eventId: 'e1', destinationCalendarId: 'team@example.com', sendUpdates: 'all' as const };
60
+
61
+ it.each([undefined, 'none'] as const)('moves without reading or asking when sendUpdates is %s', async (sendUpdates) => {
62
+ stub({});
63
+ const { harness, seen } = await prompted();
64
+ await harness.callTool('gog_calendar_move', { ...ARGS, sendUpdates });
65
+ expect(seen).toHaveLength(0);
66
+ expect(callsTo('calendar', 'event')).toHaveLength(0);
67
+ expect(callsTo('calendar', 'move')).toHaveLength(1);
68
+ });
69
+
70
+ it('reads the event and asks, naming it, its guests and who is emailed', async () => {
71
+ stub({ 'calendar event': EVENT() });
72
+ const { harness, details } = await prompted();
73
+ await harness.callTool('gog_calendar_move', { ...ARGS, sendUpdates: 'externalOnly' });
74
+ expect(details()).toEqual({
75
+ calendarId: 'primary', eventId: 'e1', destinationCalendarId: 'team@example.com',
76
+ event: { summary: 'Budget review', start: '2026-09-30T14:00:00-04:00', end: '2026-09-30T15:00:00-04:00', organizer: 'me@example.com', guests: ['boss@example.com'] },
77
+ emails: 'guests outside your domain',
78
+ });
79
+ expect(callsTo('calendar', 'move')[0]![0]).toEqual(
80
+ ['calendar', 'move', pos('primary'), pos('e1'), pos('team@example.com'), '--send-updates=externalOnly']);
81
+ });
82
+
83
+ it('moves nothing when the user declines, or when the read fails', async () => {
84
+ stub({ 'calendar event': EVENT() });
85
+ const declined = await prompted({ action: 'decline' });
86
+ expect(json(await declined.harness.callTool('gog_calendar_move', ARGS))).toMatchObject({ cancelled: true, action: 'calendar.move' });
87
+ stub({}, 'calendar event');
88
+ const { harness, seen } = await prompted();
89
+ expect((await harness.callTool('gog_calendar_move', ARGS)).isError).toBe(true);
90
+ expect(seen).toHaveLength(0);
91
+ expect(callsTo('calendar', 'move')).toHaveLength(0);
92
+ });
93
+
94
+ it('refuses a client that cannot be prompted, pointing at sendUpdates none', async () => {
95
+ process.env.MCP_CONFIRM_MODE = 'refuse';
96
+ stub({ 'calendar event': EVENT() });
97
+ const r = json(await (await unprompted()).callTool('gog_calendar_move', ARGS));
98
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'calendar.move' });
99
+ expect(r.note).toMatch(/sendUpdates none/);
100
+ });
101
+
102
+ it('token fallback: an event edited between the phases (etag rotated) is DRAFT_CHANGED', async () => {
103
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
104
+ stub({ 'calendar event': EVENT() });
105
+ const harness = await unprompted();
106
+ const p1 = json(await harness.callTool('gog_calendar_move', ARGS));
107
+ expect(p1.preview).toMatchObject({ event: { summary: 'Budget review', guests: ['boss@example.com'] }, emails: 'every guest' });
108
+ stub({ 'calendar event': EVENT('"v2"') });
109
+ expect(json(await harness.callTool('gog_calendar_move', { ...ARGS, confirmToken: p1.confirmToken })))
110
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
111
+ stub({ 'calendar event': EVENT() });
112
+ await harness.callTool('gog_calendar_move', { ...ARGS, confirmToken: p1.confirmToken });
113
+ expect(callsTo('calendar', 'move')).toHaveLength(1);
114
+ });
115
+ });
116
+
117
+ describe('gog_calendar_out_of_office', () => {
118
+ const ARGS = { from: '2026-10-01', to: '2026-10-05' };
119
+
120
+ it('creates a block that declines nothing without asking', async () => {
121
+ stub({});
122
+ const { harness, seen } = await prompted();
123
+ await harness.callTool('gog_calendar_out_of_office', { ...ARGS, autoDecline: 'none' });
124
+ expect(seen).toHaveLength(0);
125
+ expect(callsTo('calendar', 'out-of-office')).toHaveLength(1);
126
+ });
127
+
128
+ it("asks by default, because gog's default declines every conflicting meeting", async () => {
129
+ stub({});
130
+ const { harness, details } = await prompted();
131
+ await harness.callTool('gog_calendar_out_of_office', ARGS);
132
+ expect(details()).toEqual({
133
+ calendarId: 'primary', from: '2026-10-01', to: '2026-10-05', allDay: false,
134
+ summary: 'Out of office', declines: 'every existing and new conflicting meeting',
135
+ });
136
+ expect(callsTo('calendar', 'out-of-office')).toHaveLength(1);
137
+ });
138
+
139
+ it('asks for new-only declines too, showing the message', async () => {
140
+ stub({});
141
+ const { harness, details } = await prompted({ action: 'decline' });
142
+ await harness.callTool('gog_calendar_out_of_office', { ...ARGS, calendarId: 'work', summary: 'Leave', autoDecline: 'new', declineMessage: 'Back Monday', allDay: true });
143
+ expect(details()).toEqual({
144
+ calendarId: 'work', from: '2026-10-01', to: '2026-10-05', allDay: true,
145
+ summary: 'Leave', declines: 'new conflicting invitations', declineMessage: 'Back Monday',
146
+ });
147
+ expect(callsTo('calendar', 'out-of-office')).toHaveLength(0);
148
+ });
149
+
150
+ it('refuses a client that cannot be prompted, pointing at autoDecline none', async () => {
151
+ process.env.MCP_CONFIRM_MODE = 'refuse';
152
+ const r = json(await (await unprompted()).callTool('gog_calendar_out_of_office', ARGS));
153
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'calendar.out-of-office' });
154
+ expect(r.note).toMatch(/autoDecline none/);
155
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('token fallback: a changed message is DRAFT_CHANGED; the same call creates', async () => {
159
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
160
+ stub({});
161
+ const harness = await unprompted();
162
+ const args = { ...ARGS, declineMessage: 'Away' };
163
+ const p1 = json(await harness.callTool('gog_calendar_out_of_office', args));
164
+ expect(p1.status).toBe('confirmation-required');
165
+ expect(json(await harness.callTool('gog_calendar_out_of_office', { ...args, declineMessage: 'Gone', confirmToken: p1.confirmToken })))
166
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
167
+ await harness.callTool('gog_calendar_out_of_office', { ...args, confirmToken: p1.confirmToken });
168
+ expect(callsTo('calendar', 'out-of-office')).toHaveLength(1);
169
+ });
170
+ });
171
+
172
+ describe('gog_calendar_delete_calendar', () => {
173
+ const CAL = (etag = '"c1"') => JSON.stringify({ id: 'cal1', summary: 'Team offsite', etag });
174
+
175
+ it('reads the calendar and asks by name', async () => {
176
+ stub({ 'api call calendar': CAL() });
177
+ const { harness, details, seen } = await prompted();
178
+ await harness.callTool('gog_calendar_delete_calendar', { calendarId: 'cal1' });
179
+ expect(seen[0]!.params.message).toMatch(/PERMANENTLY/);
180
+ expect(details()).toEqual({ calendar: { id: 'cal1', summary: 'Team offsite' }, deletes: 'the calendar and every event on it, for good' });
181
+ expect(callsTo('api', 'call')[0]![0]).toEqual(['api', 'call', 'calendar', 'v3', 'calendars.get', '--params={"calendarId":"cal1"}']);
182
+ expect(callsTo('calendar', 'delete-calendar')).toHaveLength(1);
183
+ });
184
+
185
+ it('deletes nothing when the user declines, or when the read fails', async () => {
186
+ stub({ 'api call calendar': CAL() });
187
+ await (await prompted({ action: 'decline' })).harness.callTool('gog_calendar_delete_calendar', { calendarId: 'cal1' });
188
+ stub({}, 'api call calendar');
189
+ expect((await (await prompted()).harness.callTool('gog_calendar_delete_calendar', { calendarId: 'cal1' })).isError).toBe(true);
190
+ expect(callsTo('calendar', 'delete-calendar')).toHaveLength(0);
191
+ });
192
+
193
+ it('refuses a client that cannot be prompted', async () => {
194
+ process.env.MCP_CONFIRM_MODE = 'refuse';
195
+ stub({ 'api call calendar': CAL() });
196
+ const r = json(await (await unprompted()).callTool('gog_calendar_delete_calendar', { calendarId: 'cal1' }));
197
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'calendar.delete-calendar' });
198
+ expect(r.note).toMatch(/gog_calendar_unsubscribe/);
199
+ });
200
+
201
+ it('token fallback: a calendar changed between the phases (etag rotated) is DRAFT_CHANGED', async () => {
202
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
203
+ stub({ 'api call calendar': CAL() });
204
+ const harness = await unprompted();
205
+ const p1 = json(await harness.callTool('gog_calendar_delete_calendar', { calendarId: 'cal1' }));
206
+ expect(p1.preview.calendar).toEqual({ id: 'cal1', summary: 'Team offsite' });
207
+ stub({ 'api call calendar': CAL('"c2"') });
208
+ expect(json(await harness.callTool('gog_calendar_delete_calendar', { calendarId: 'cal1', confirmToken: p1.confirmToken })))
209
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
210
+ stub({ 'api call calendar': CAL() });
211
+ await harness.callTool('gog_calendar_delete_calendar', { calendarId: 'cal1', confirmToken: p1.confirmToken });
212
+ expect(callsTo('calendar', 'delete-calendar')).toHaveLength(1);
213
+ });
214
+
215
+ it('calendarSnapshot reads a bare or result-wrapped calendar and tolerates unreadable output', () => {
216
+ expect(calendarSnapshot(JSON.stringify({ result: { summary: 'X', etag: 'e' } }), 'c')).toEqual({ id: 'c', summary: 'X', etag: 'e' });
217
+ expect(calendarSnapshot('{"summary":7}', 'c')).toEqual({ id: 'c' });
218
+ expect(calendarSnapshot('null', 'c')).toEqual({ id: 'c' });
219
+ expect(calendarSnapshot('nope', 'c')).toEqual({ id: 'c' });
220
+ });
221
+ });
@@ -18,7 +18,11 @@ let harness: TestHarness;
18
18
  beforeEach(async () => {
19
19
  vi.clearAllMocks();
20
20
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
21
- harness = await createTestHarness(registerExtraCalendarTools);
21
+ // The gated tools (move with sendUpdates, out-of-office, delete-calendar) are
22
+ // exercised prompt-by-prompt in calendar-extra-gates.test.ts; here the user accepts.
23
+ harness = await createTestHarness(registerExtraCalendarTools, {
24
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
25
+ });
22
26
  });
23
27
 
24
28
  describe('gog_meet_create', () => {