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 +175 -29
- package/manifest.json +5 -5
- package/package.json +1 -1
- package/src/tools/calendar-extra.ts +114 -9
- package/tests/tools/calendar-extra-gates.test.ts +221 -0
- package/tests/tools/calendar-extra.test.ts +5 -1
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
|
|
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
|
|
36210
|
-
|
|
36211
|
-
|
|
36212
|
-
|
|
36213
|
-
|
|
36214
|
-
|
|
36215
|
-
|
|
36216
|
-
|
|
36217
|
-
|
|
36218
|
-
|
|
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,
|
|
36243
|
+
function vetCalendarRun(subcommand, args) {
|
|
36221
36244
|
const sub = subcommand.toLowerCase();
|
|
36222
|
-
const
|
|
36223
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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', () => {
|