gogcli-mcp 4.3.0 → 4.4.0

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/lib.js CHANGED
@@ -33289,9 +33289,9 @@ function offsetAt(instant, tz) {
33289
33289
  const w = wallPartsIn(instant, tz);
33290
33290
  const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
33291
33291
  const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
33292
- const sign2 = minutes < 0 ? "-" : "+";
33292
+ const sign3 = minutes < 0 ? "-" : "+";
33293
33293
  const abs = Math.abs(minutes);
33294
- return `${sign2}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
33294
+ return `${sign3}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
33295
33295
  }
33296
33296
  function wallPartsIn(instant, tz) {
33297
33297
  const parts = new Intl.DateTimeFormat("en-US", {
@@ -34199,13 +34199,239 @@ function assertNotBoth(inlineParam, fileParam, inlineValue, fileValue) {
34199
34199
  }
34200
34200
  }
34201
34201
 
34202
+ // src/dispatch-confirmation.ts
34203
+ import { createHash as createHash3 } from "node:crypto";
34204
+ import { readFileSync } from "node:fs";
34205
+
34206
+ // src/send-confirm-token.ts
34207
+ import { createHash as createHash2, createHmac as createHmac2, randomBytes, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
34208
+ var CONFIRM_TOKEN_TTL_DEFAULT_SECONDS = 600;
34209
+ var PREFIX = "gct1";
34210
+ var secret;
34211
+ var spent = /* @__PURE__ */ new Map();
34212
+ function sendConfirmFallbackEnabled() {
34213
+ return readEnvVar("GOG_SEND_CONFIRM_FALLBACK")?.trim().toLowerCase() === "token";
34214
+ }
34215
+ function confirmTokenTtlSeconds() {
34216
+ const raw = readEnvVar("GOG_CONFIRM_TTL_SECONDS")?.trim();
34217
+ if (raw && /^\d+$/.test(raw)) {
34218
+ const n = Number(raw);
34219
+ if (n > 0) return n;
34220
+ }
34221
+ return CONFIRM_TOKEN_TTL_DEFAULT_SECONDS;
34222
+ }
34223
+ function hmacKey() {
34224
+ if (!secret) {
34225
+ const configured = readEnvVar("GOG_CONFIRM_SECRET");
34226
+ secret = configured ? Buffer.from(configured, "utf8") : randomBytes(32);
34227
+ }
34228
+ return secret;
34229
+ }
34230
+ function canonicalize(value) {
34231
+ if (Array.isArray(value)) return value.map(canonicalize);
34232
+ if (value && typeof value === "object") {
34233
+ const out = {};
34234
+ for (const key of Object.keys(value).sort()) {
34235
+ const v = value[key];
34236
+ if (v !== void 0) out[key] = canonicalize(v);
34237
+ }
34238
+ return out;
34239
+ }
34240
+ return value;
34241
+ }
34242
+ function hashSendPayload(payload) {
34243
+ return createHash2("sha256").update(JSON.stringify(canonicalize(payload))).digest("hex");
34244
+ }
34245
+ function sign(body) {
34246
+ return createHmac2("sha256", hmacKey()).update(`${PREFIX}.${body}`).digest("base64url");
34247
+ }
34248
+ function issueConfirmToken(binding, now = Date.now()) {
34249
+ const exp = now + confirmTokenTtlSeconds() * 1e3;
34250
+ const claims = {
34251
+ t: binding.tool,
34252
+ a: binding.account,
34253
+ g: binding.target,
34254
+ ...binding.revision === void 0 ? {} : { r: binding.revision },
34255
+ h: binding.payloadHash,
34256
+ iat: now,
34257
+ exp,
34258
+ n: randomBytes(16).toString("base64url")
34259
+ };
34260
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
34261
+ return { token: `${PREFIX}.${body}.${sign(body)}`, expiresAt: new Date(exp).toISOString() };
34262
+ }
34263
+ function signatureMatches(body, sig) {
34264
+ const expected = Buffer.from(sign(body), "utf8");
34265
+ const actual = Buffer.from(sig, "utf8");
34266
+ return expected.length === actual.length && timingSafeEqual2(expected, actual);
34267
+ }
34268
+ function parseClaims(body) {
34269
+ try {
34270
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
34271
+ } catch {
34272
+ return void 0;
34273
+ }
34274
+ }
34275
+ function verifyConfirmToken(token, binding, now = Date.now()) {
34276
+ for (const [nonce, exp] of spent) if (exp < now) spent.delete(nonce);
34277
+ const parts = token.split(".");
34278
+ if (parts.length !== 3 || parts[0] !== PREFIX || !parts[1] || !parts[2]) return { ok: false, error: "TOKEN_INVALID" };
34279
+ const [, body, sig] = parts;
34280
+ if (!signatureMatches(body, sig)) return { ok: false, error: "TOKEN_INVALID" };
34281
+ const claims = parseClaims(body);
34282
+ if (!claims) return { ok: false, error: "TOKEN_INVALID" };
34283
+ if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
34284
+ return { ok: false, error: "TOKEN_INVALID" };
34285
+ }
34286
+ if (spent.has(claims.n)) return { ok: false, error: "TOKEN_REUSED" };
34287
+ if (now > claims.exp) return { ok: false, error: "TOKEN_EXPIRED" };
34288
+ if (claims.r !== binding.revision) return { ok: false, error: "DRAFT_CHANGED", reason: "message-id-rotated" };
34289
+ if (claims.h !== binding.payloadHash) return { ok: false, error: "DRAFT_CHANGED", reason: "payload-changed" };
34290
+ spent.set(claims.n, claims.exp);
34291
+ return { ok: true };
34292
+ }
34293
+
34294
+ // src/dispatch-confirmation.ts
34295
+ var BODY_PREVIEW_MAX = 2048;
34296
+ function bodyPreview(text) {
34297
+ if (!text) return void 0;
34298
+ if (text.length <= BODY_PREVIEW_MAX) return text;
34299
+ return `${text.slice(0, BODY_PREVIEW_MAX)}\u2026 [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
34300
+ }
34301
+ function attachmentNames(paths, inline) {
34302
+ return [...paths ?? [], ...(inline ?? []).map((a) => a.filename)];
34303
+ }
34304
+ function attachmentDetails(paths, inline) {
34305
+ const out = [];
34306
+ for (const path of paths ?? []) {
34307
+ let bytes;
34308
+ try {
34309
+ bytes = readFileSync(path);
34310
+ } catch {
34311
+ bytes = void 0;
34312
+ }
34313
+ out.push(bytes ? { name: path, size: bytes.length, sha256: createHash3("sha256").update(bytes).digest("hex") } : { name: path, size: null });
34314
+ }
34315
+ for (const a of inline ?? []) {
34316
+ out.push({
34317
+ name: a.filename,
34318
+ size: Buffer.from(a.contentBase64, "base64").length,
34319
+ sha256: createHash3("sha256").update(a.contentBase64).digest("hex")
34320
+ });
34321
+ }
34322
+ return out;
34323
+ }
34324
+ function senderPreview(account, from) {
34325
+ return from ?? account ?? readEnvVar("GOG_ACCOUNT") ?? "the gog account's default address";
34326
+ }
34327
+ function attachmentPreview(details) {
34328
+ return details.map(({ name, size }) => ({ name, size }));
34329
+ }
34330
+ var CONFIRM_SEND_INSTRUCTION = "Show this preview to the user verbatim and send only after they explicitly approve in chat. Then call again with confirmToken.";
34331
+ var CONFIRM_ACTION_INSTRUCTION = "Show this preview to the user verbatim and proceed only after they explicitly approve in chat. Then call again with confirmToken.";
34332
+ var FALLBACK_HINT = "Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.";
34333
+ var CONFIRM_FALLBACK_DESCRIPTION = ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai) and the server sets GOG_SEND_CONFIRM_FALLBACK=token, 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. Show that preview to the user verbatim; 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.';
34334
+ var confirmTokenParam = external_exports.string().optional().describe(
34335
+ `ONLY for the two-step fallback (client without MCP elicitation, server with GOG_SEND_CONFIRM_FALLBACK=token). The confirmToken from this same tool's phase-1 "confirmation-required" response, passed back ONLY after the user has seen that preview and explicitly approved it in chat \u2014 never on the first call, never invented, never reused. Call again with the same arguments. Ignored when the client supports elicitation.`
34336
+ );
34337
+ var TOKEN_ERROR_NOTE = {
34338
+ TOKEN_EXPIRED: "Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, and ask the user to approve it again.",
34339
+ TOKEN_REUSED: "Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.",
34340
+ TOKEN_INVALID: "Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval."
34341
+ };
34342
+ var DRAFT_CHANGED_NOTE = {
34343
+ "message-id-rotated": "Nothing was sent or changed: the target was edited since the user approved it (a draft's messageId or an event's version rotated), so what would happen is not what they saw.",
34344
+ "payload-changed": "Nothing was sent or changed: what would happen no longer matches what the user approved."
34345
+ };
34346
+ function isToolResult(value) {
34347
+ return Array.isArray(value.content);
34348
+ }
34349
+ function rejection(data) {
34350
+ return { ...textResult({ status: "confirmation-rejected", confirmed: false, dispatched: false, ...data }), isError: true };
34351
+ }
34352
+ async function tokenConfirmation(op, fallback) {
34353
+ const subject = await fallback.subject();
34354
+ if (isToolResult(subject)) return subject;
34355
+ const binding = {
34356
+ tool: fallback.tool,
34357
+ account: fallback.account ?? readEnvVar("GOG_ACCOUNT") ?? "",
34358
+ target: subject.target,
34359
+ ...subject.revision === void 0 ? {} : { revision: subject.revision },
34360
+ payloadHash: hashSendPayload(subject.payload)
34361
+ };
34362
+ const phaseOne = () => {
34363
+ const { token, expiresAt } = issueConfirmToken(binding);
34364
+ return {
34365
+ action: op,
34366
+ preview: subject.preview,
34367
+ confirmToken: token,
34368
+ expiresAt,
34369
+ ttlSeconds: confirmTokenTtlSeconds(),
34370
+ instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION
34371
+ };
34372
+ };
34373
+ if (!fallback.confirmToken) {
34374
+ return textResult({ status: "confirmation-required", confirmed: false, dispatched: false, ...phaseOne() });
34375
+ }
34376
+ const verdict = verifyConfirmToken(fallback.confirmToken, binding);
34377
+ if (verdict.ok) return void 0;
34378
+ if (verdict.error === "DRAFT_CHANGED") {
34379
+ return rejection({
34380
+ error: "DRAFT_CHANGED",
34381
+ reason: verdict.reason,
34382
+ note: `${DRAFT_CHANGED_NOTE[verdict.reason]} The current preview and a fresh confirmToken are below.`,
34383
+ ...phaseOne()
34384
+ });
34385
+ }
34386
+ return rejection({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
34387
+ }
34388
+ function gatedElsewhere(what, via, does, tool) {
34389
+ return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
34390
+ }
34391
+ function hasCommandWord(args, words) {
34392
+ return args.find((a) => words.has(a.toLowerCase()));
34393
+ }
34394
+ async function requireDispatchConfirmation(ctx, options) {
34395
+ const { action, fallback } = options;
34396
+ if (fallback && callerAcceptsFormElicitation(ctx) === false && sendConfirmFallbackEnabled()) {
34397
+ return tokenConfirmation(action, fallback);
34398
+ }
34399
+ const note = fallback ? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(" ") : options.unsupportedNote;
34400
+ return requireConfirmation(ctx, {
34401
+ action,
34402
+ message: options.message,
34403
+ details: options.details,
34404
+ confirmationLabel: options.confirmationLabel,
34405
+ ...note ? { unsupportedNote: note } : {}
34406
+ });
34407
+ }
34408
+ function resultText(result) {
34409
+ const first = result.content[0];
34410
+ return first && first.type === "text" && typeof first.text === "string" ? first.text : "{}";
34411
+ }
34412
+
34202
34413
  // src/tools/api.ts
34203
34414
  var GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
34415
+ var DISPATCH_API_BLOCKED = {
34416
+ chat: [{ method: /(?:^|\.)spaces\.messages\.create$/i, does: "posts a Chat message", tool: "gog_chat_messages_send / gog_chat_dm_send" }],
34417
+ drive: [{ method: /(?:^|\.)permissions\.(?:create|update)$/i, does: "grants access to a file", tool: "gog_drive_share" }],
34418
+ classroom: [
34419
+ { method: /(?:^|\.)courses\.announcements\.create$/i, does: "posts to a class", tool: "gog_classroom_announcements_create" },
34420
+ { method: /(?:^|\.)invitations\.create$/i, does: "invites someone to a class", tool: "gog_classroom_invitations_create" }
34421
+ ],
34422
+ calendar: [
34423
+ { method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: "can put an event on guests' calendars", tool: "gog_calendar_create" },
34424
+ { method: /(?:^|\.)events\.(?:update|patch)$/i, does: "can change what guests see", tool: "gog_calendar_update / gog_calendar_respond" }
34425
+ ]
34426
+ };
34204
34427
  function refusedApiCall(api, method) {
34205
- if (api.trim().toLowerCase() === "gmail" && GMAIL_API_BLOCKED.test(method.trim())) {
34428
+ const name = api.trim().toLowerCase();
34429
+ const m = method.trim();
34430
+ if (name === "gmail" && GMAIL_API_BLOCKED.test(m)) {
34206
34431
  return `gmail ${method} is not available through gog_api_call: it sends or forwards mail. Use gog_gmail_send / gog_gmail_drafts_send (which ask the user to confirm) or the dedicated gog_gmail_* tool.`;
34207
34432
  }
34208
- return void 0;
34433
+ const hit = (Object.hasOwn(DISPATCH_API_BLOCKED, name) ? DISPATCH_API_BLOCKED[name] : []).find((b) => b.method.test(m));
34434
+ return hit ? gatedElsewhere(`${name} ${m}`, "gog_api_call", hit.does, hit.tool) : void 0;
34209
34435
  }
34210
34436
  function registerApiTools(server) {
34211
34437
  server.registerTool("gog_api_list", {
@@ -34235,7 +34461,7 @@ function registerApiTools(server) {
34235
34461
  return runOrDiagnose(args, { account });
34236
34462
  });
34237
34463
  server.registerTool("gog_api_call", {
34238
- description: "Call any Discovery-described Google API method directly \u2014 an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true \u2014 keep it false to preview, or set dryRun=true to print the intended request without sending it. Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused \u2014 use the dedicated gog_gmail_* tools, which ask the user to confirm.",
34464
+ description: "Call any Discovery-described Google API method directly \u2014 an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true \u2014 keep it false to preview, or set dryRun=true to print the intended request without sending it. Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused \u2014 use the dedicated gog_gmail_* tools, which ask the user to confirm. So are the other methods a dedicated tool asks about: Chat spaces.messages.create, Drive permissions.create/update, Classroom courses.announcements.create and invitations.create, and Calendar events.insert/import/quickAdd/update/patch.",
34239
34465
  annotations: { destructiveHint: true },
34240
34466
  inputSchema: external_exports.object({
34241
34467
  api: external_exports.string().describe("Discovery API name (e.g. drive, gmail, calendar)"),
@@ -34521,6 +34747,54 @@ function authToolsFor(defaultServices) {
34521
34747
  }
34522
34748
 
34523
34749
  // src/tools/calendar.ts
34750
+ var CALENDAR_GATED = {
34751
+ create: "gog_calendar_create",
34752
+ add: "gog_calendar_create",
34753
+ new: "gog_calendar_create",
34754
+ update: "gog_calendar_update",
34755
+ edit: "gog_calendar_update",
34756
+ set: "gog_calendar_update",
34757
+ respond: "gog_calendar_respond",
34758
+ rsvp: "gog_calendar_respond",
34759
+ reply: "gog_calendar_respond"
34760
+ };
34761
+ function vetCalendarRun(subcommand, _args) {
34762
+ const sub = subcommand.toLowerCase();
34763
+ const tool = Object.hasOwn(CALENDAR_GATED, sub) ? CALENDAR_GATED[sub] : void 0;
34764
+ return tool ? gatedElsewhere(`gog calendar ${sub}`, "gog_calendar_run", "can change what guests see", tool) : void 0;
34765
+ }
34766
+ function parseAttendees(value) {
34767
+ if (!value) return [];
34768
+ return value.split(",").map((entry) => entry.split(";")).filter((parts) => !parts.slice(1).some((m) => m.trim().toLowerCase() === "resource")).map((parts) => parts[0].trim()).filter(Boolean);
34769
+ }
34770
+ function timeOf(t) {
34771
+ const v = t?.dateTime ?? t?.date;
34772
+ return typeof v === "string" ? v : void 0;
34773
+ }
34774
+ function eventSnapshot(raw) {
34775
+ let event;
34776
+ try {
34777
+ event = JSON.parse(raw)?.event;
34778
+ } catch {
34779
+ event = void 0;
34780
+ }
34781
+ const str = (v) => typeof v === "string" ? v : void 0;
34782
+ return {
34783
+ summary: str(event?.summary),
34784
+ start: timeOf(event?.start),
34785
+ end: timeOf(event?.end),
34786
+ organizer: str(event?.organizer?.email),
34787
+ // Guests are the OTHER people: not this account's own attendee row, not rooms.
34788
+ guests: (Array.isArray(event?.attendees) ? event.attendees : []).filter((a) => a.self !== true && a.resource !== true && typeof a.email === "string").map((a) => a.email),
34789
+ etag: str(event?.etag)
34790
+ };
34791
+ }
34792
+ async function readEvent(calendarId, eventId, account) {
34793
+ const got = await runOrDiagnose(["calendar", "event", pos(calendarId), pos(eventId)], { account });
34794
+ if (got.isError) return { error: got };
34795
+ return { event: eventSnapshot(resultText(got)) };
34796
+ }
34797
+ var CALENDAR_CONFIRM_LABEL = "Confirm that this change, which other people will see, should be made now.";
34524
34798
  var reminderParams = {
34525
34799
  reminders: external_exports.array(external_exports.string()).max(5).optional().describe(
34526
34800
  `Reminders as method:duration, e.g. ["popup:30m", "email:1d"]. Method is popup or email; duration accepts m/h/d (max 40320 minutes = 4 weeks). Google allows at most 5. These REPLACE the event's reminders \u2014 on update, pass an EMPTY array to drop custom reminders and go back to the calendar's defaults. Cannot be combined with noReminders.`
@@ -34608,7 +34882,7 @@ function registerCalendarTools(server) {
34608
34882
  return runOrDiagnose(args, { account });
34609
34883
  });
34610
34884
  server.registerTool("gog_calendar_create", {
34611
- description: "Create a calendar event. Set withZoom=true to attach a Zoom meeting (requires Zoom S2S OAuth setup via gog_zoom_auth_setup; the join URL + meeting ID + passcode are appended to the event description \u2014 Google rejects native conference card writes from non-Workspace-Marketplace OAuth clients).",
34885
+ description: "Create a calendar event. Set withZoom=true to attach a Zoom meeting (requires Zoom S2S OAuth setup via gog_zoom_auth_setup; the join URL + meeting ID + passcode are appended to the event description \u2014 Google rejects native conference card writes from non-Workspace-Marketplace OAuth clients). With attendees, the event lands on other people's calendars (no invitation email is sent), so the MCP host is asked to show the user a confirmation prompt with the guests, time and details first; a guest-free event is created without asking." + CONFIRM_FALLBACK_DESCRIPTION,
34612
34886
  annotations: { destructiveHint: false },
34613
34887
  inputSchema: external_exports.object({
34614
34888
  calendarId: external_exports.string().describe('Calendar ID (use "primary" for the default calendar)'),
@@ -34622,9 +34896,10 @@ function registerCalendarTools(server) {
34622
34896
  timezone: external_exports.string().optional().describe("IANA timezone metadata applied to from/to (e.g. America/New_York). Sets both start and end timezone unless start/end timezone are overridden."),
34623
34897
  withZoom: external_exports.boolean().optional().describe("Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)"),
34624
34898
  ...reminderParams,
34625
- account: accountParam
34899
+ account: accountParam,
34900
+ confirmToken: confirmTokenParam
34626
34901
  })
34627
- }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account }) => {
34902
+ }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account, confirmToken }, ctx) => {
34628
34903
  const args = ["calendar", "create", pos(calendarId), `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
34629
34904
  if (description) args.push(`--description=${description}`);
34630
34905
  if (location) args.push(`--location=${location}`);
@@ -34633,10 +34908,28 @@ function registerCalendarTools(server) {
34633
34908
  if (timezone) args.push(`--timezone=${timezone}`);
34634
34909
  if (withZoom) args.push("--with-zoom");
34635
34910
  pushReminderFlags(args, { reminders, noReminders });
34911
+ const guests = parseAttendees(attendees);
34912
+ if (guests.length > 0) {
34913
+ const event = { calendarId, summary, from, to, allDay: Boolean(allDay), timezone, location, description, guests, attendees, withZoom: Boolean(withZoom) };
34914
+ const confirmation = await requireDispatchConfirmation(ctx, {
34915
+ action: "calendar.create",
34916
+ message: "Review and confirm this event, which will appear on the guests' calendars:",
34917
+ confirmationLabel: CALENDAR_CONFIRM_LABEL,
34918
+ details: event,
34919
+ unsupportedNote: "Create it without attendees instead; the user can add the guests in Google Calendar.",
34920
+ fallback: {
34921
+ tool: "gog_calendar_create",
34922
+ account,
34923
+ confirmToken,
34924
+ subject: () => ({ target: calendarId, payload: event, preview: event })
34925
+ }
34926
+ });
34927
+ if (confirmation) return confirmation;
34928
+ }
34636
34929
  return runOrDiagnose(args, { account });
34637
34930
  });
34638
34931
  server.registerTool("gog_calendar_update", {
34639
- description: "Update an existing calendar event. Zoom: withZoom adds a Zoom meeting, regenerateZoom replaces the existing one, removeZoom strips it. removeMeet clears the event's Google Meet conference data (e.g. before attaching another provider). Conference flags are independent \u2014 use one per call.",
34932
+ description: "Update an existing calendar event. Zoom: withZoom adds a Zoom meeting, regenerateZoom replaces the existing one, removeZoom strips it. removeMeet clears the event's Google Meet conference data (e.g. before attaching another provider). Conference flags are independent \u2014 use one per call. A change guests can see (time, title, place, description, attendees, attachments, conferencing) on an event that has guests \u2014 or gains them \u2014 reads the event and asks the MCP host to show the user a confirmation prompt with the event as it stands and the change; no invitation email is sent. Reminder-only changes and guest-free events are updated without asking." + CONFIRM_FALLBACK_DESCRIPTION,
34640
34933
  annotations: { destructiveHint: false },
34641
34934
  inputSchema: external_exports.object({
34642
34935
  calendarId: external_exports.string().describe("Calendar ID"),
@@ -34654,9 +34947,10 @@ function registerCalendarTools(server) {
34654
34947
  removeZoom: external_exports.boolean().optional().describe("Remove the event's Zoom video conference"),
34655
34948
  removeMeet: external_exports.boolean().optional().describe("Remove the event's Google Meet video conference (clears conference data only)"),
34656
34949
  ...reminderParams,
34657
- account: accountParam
34950
+ account: accountParam,
34951
+ confirmToken: confirmTokenParam
34658
34952
  })
34659
- }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account }) => {
34953
+ }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account, confirmToken }, ctx) => {
34660
34954
  const args = ["calendar", "update", pos(calendarId), pos(eventId)];
34661
34955
  if (summary !== void 0) args.push(`--summary=${summary}`);
34662
34956
  if (from !== void 0) args.push(`--from=${from}`);
@@ -34671,6 +34965,45 @@ function registerCalendarTools(server) {
34671
34965
  if (removeZoom) args.push("--remove-zoom");
34672
34966
  if (removeMeet) args.push("--remove-meet");
34673
34967
  pushReminderFlags(args, { reminders, noReminders });
34968
+ const changes = Object.fromEntries(Object.entries({
34969
+ summary,
34970
+ from,
34971
+ to,
34972
+ description,
34973
+ location,
34974
+ attendees,
34975
+ addAttendees,
34976
+ attachments,
34977
+ withZoom,
34978
+ regenerateZoom,
34979
+ removeZoom,
34980
+ removeMeet
34981
+ }).filter(([, v]) => v !== void 0 && v !== false));
34982
+ if (Object.keys(changes).length > 0) {
34983
+ const read = await readEvent(calendarId, eventId, account);
34984
+ if (read.error) return read.error;
34985
+ const { etag, ...current } = read.event;
34986
+ const added = [...parseAttendees(attendees), ...parseAttendees(addAttendees)];
34987
+ if (current.guests.length > 0 || added.length > 0) {
34988
+ const view = { calendarId, eventId, current, changes };
34989
+ const confirmation = await requireDispatchConfirmation(ctx, {
34990
+ action: "calendar.update",
34991
+ message: "Review and confirm this change to an event other people are on:",
34992
+ confirmationLabel: CALENDAR_CONFIRM_LABEL,
34993
+ details: view,
34994
+ unsupportedNote: "Ask the user to make this change in Google Calendar.",
34995
+ fallback: {
34996
+ tool: "gog_calendar_update",
34997
+ account,
34998
+ confirmToken,
34999
+ // The etag rotates on any edit, so an event changed elsewhere between
35000
+ // the two phases is DRAFT_CHANGED rather than edited over.
35001
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view })
35002
+ }
35003
+ });
35004
+ if (confirmation) return confirmation;
35005
+ }
35006
+ }
34674
35007
  return runOrDiagnose(args, { account });
34675
35008
  });
34676
35009
  server.registerTool("gog_calendar_delete", {
@@ -34685,21 +35018,40 @@ function registerCalendarTools(server) {
34685
35018
  return runOrDiagnose(["calendar", "delete", pos(calendarId), pos(eventId), "--force"], { account });
34686
35019
  });
34687
35020
  server.registerTool("gog_calendar_respond", {
34688
- description: "Respond to a calendar event invitation.",
35021
+ description: "Respond to a calendar event invitation. The organizer sees the response, so this reads the event and asks the MCP host to show the user a confirmation prompt with the event, organizer, response and comment; nothing is recorded unless they accept." + CONFIRM_FALLBACK_DESCRIPTION,
34689
35022
  annotations: { destructiveHint: true },
34690
35023
  inputSchema: external_exports.object({
34691
35024
  calendarId: external_exports.string().describe("Calendar ID"),
34692
35025
  eventId: external_exports.string().describe("Event ID"),
34693
35026
  status: external_exports.enum(["accepted", "declined", "tentative"]).describe("Response status"),
34694
35027
  comment: external_exports.string().optional().describe("Optional comment to include with response"),
34695
- account: accountParam
35028
+ account: accountParam,
35029
+ confirmToken: confirmTokenParam
34696
35030
  })
34697
- }, async ({ calendarId, eventId, status, comment, account }) => {
35031
+ }, async ({ calendarId, eventId, status, comment, account, confirmToken }, ctx) => {
34698
35032
  const args = ["calendar", "respond", pos(calendarId), pos(eventId), `--status=${status}`];
34699
35033
  if (comment) args.push(`--comment=${comment}`);
35034
+ const read = await readEvent(calendarId, eventId, account);
35035
+ if (read.error) return read.error;
35036
+ const { etag, ...event } = read.event;
35037
+ const view = { calendarId, eventId, event, response: status, ...comment ? { comment } : {} };
35038
+ const confirmation = await requireDispatchConfirmation(ctx, {
35039
+ action: "calendar.respond",
35040
+ message: "Review and confirm this response, which the organizer will see:",
35041
+ confirmationLabel: "Confirm that this response should be recorded now.",
35042
+ details: view,
35043
+ unsupportedNote: "Ask the user to respond from Google Calendar.",
35044
+ fallback: {
35045
+ tool: "gog_calendar_respond",
35046
+ account,
35047
+ confirmToken,
35048
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view })
35049
+ }
35050
+ });
35051
+ if (confirmation) return confirmation;
34700
35052
  return runOrDiagnose(args, { account });
34701
35053
  });
34702
- registerRunTool(server, { service: "calendar", examples: '"calendars", "freebusy"' });
35054
+ registerRunTool(server, { service: "calendar", examples: '"calendars", "freebusy"', vet: vetCalendarRun });
34703
35055
  }
34704
35056
 
34705
35057
  // src/attachments.ts
@@ -34783,6 +35135,23 @@ function inlineAttachmentArgs(flag, attachments, siblingArgs = []) {
34783
35135
  }
34784
35136
 
34785
35137
  // src/tools/chat.ts
35138
+ var CHAT_SEND_WORDS = /* @__PURE__ */ new Set(["send", "create", "post"]);
35139
+ function vetChatRun(subcommand, args) {
35140
+ const sub = subcommand.toLowerCase();
35141
+ if (sub !== "messages" && sub !== "dm") return void 0;
35142
+ const word = hasCommandWord(args, CHAT_SEND_WORDS);
35143
+ if (!word) return void 0;
35144
+ return gatedElsewhere(
35145
+ `gog chat ${sub} ${word.toLowerCase()}`,
35146
+ "gog_chat_run",
35147
+ "posts a message",
35148
+ sub === "dm" ? "gog_chat_dm_send" : "gog_chat_messages_send"
35149
+ );
35150
+ }
35151
+ var CHAT_CONFIRM = {
35152
+ message: "Review and confirm this Google Chat message:",
35153
+ confirmationLabel: "Confirm that this Chat message should be posted now."
35154
+ };
34786
35155
  function registerChatTools(server) {
34787
35156
  const workspaceOnlyNote = ' WORKSPACE ONLY: Google Chat has no API for consumer accounts, so this fails on an @gmail.com account with "chat requires a Google Workspace account". That is the ACCOUNT, not the token \u2014 re-authorizing or adding scopes will not help.';
34788
35157
  const spaceParam = external_exports.string().describe(
@@ -34888,7 +35257,7 @@ function registerChatTools(server) {
34888
35257
  return runOrDiagnose(args, { account });
34889
35258
  });
34890
35259
  server.registerTool("gog_chat_messages_send", {
34891
- description: "Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing conversation (from gog_chat_threads_list or a message's thread field); omit it to start a new one. Text supports Chat's markdown-ish formatting (*bold*, _italic_, `code`)." + workspaceOnlyNote,
35260
+ description: "Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing conversation (from gog_chat_threads_list or a message's thread field); omit it to start a new one. Text supports Chat's markdown-ish formatting (*bold*, _italic_, `code`). Asks the MCP host to show the user a confirmation prompt with the space, thread, text and attachments first; nothing is posted unless they accept." + CONFIRM_FALLBACK_DESCRIPTION + workspaceOnlyNote,
34892
35261
  annotations: { destructiveHint: true },
34893
35262
  inputSchema: external_exports.object({
34894
35263
  space: spaceParam,
@@ -34898,9 +35267,10 @@ function registerChatTools(server) {
34898
35267
  "Attachment file paths, read WHERE GOG RUNS. On a hosted or remote deployment that is not your machine \u2014 use attachInline there instead. Must be inside the server's GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files)."
34899
35268
  ),
34900
35269
  attachInline: attachInlineParam,
34901
- account: accountParam
35270
+ account: accountParam,
35271
+ confirmToken: confirmTokenParam
34902
35272
  })
34903
- }, async ({ space, text, thread, attach, attachInline, account }) => {
35273
+ }, async ({ space, text, thread, attach, attachInline, account, confirmToken }, ctx) => {
34904
35274
  confinePaths(attach, "attach");
34905
35275
  if (text === void 0 && !attach?.length && !attachInline?.length) {
34906
35276
  throw new Error("A Chat message needs text, an attachment, or both.");
@@ -34910,20 +35280,56 @@ function registerChatTools(server) {
34910
35280
  if (thread) args.push(`--thread=${thread}`);
34911
35281
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
34912
35282
  args.push(...inlineAttachmentArgs("attach", attachInline, args));
35283
+ const confirmation = await requireDispatchConfirmation(ctx, {
35284
+ ...CHAT_CONFIRM,
35285
+ action: "chat.message-send",
35286
+ details: { space, thread, textPreview: bodyPreview(text), attachments: attachmentNames(attach, attachInline) },
35287
+ fallback: {
35288
+ tool: "gog_chat_messages_send",
35289
+ account,
35290
+ confirmToken,
35291
+ subject: () => {
35292
+ const attachments = attachmentDetails(attach, attachInline);
35293
+ const from = senderPreview(account);
35294
+ return {
35295
+ target: space,
35296
+ payload: { from, space, thread, text, attachments },
35297
+ preview: { from, space, thread, text, attachments: attachmentPreview(attachments) }
35298
+ };
35299
+ }
35300
+ }
35301
+ });
35302
+ if (confirmation) return confirmation;
34913
35303
  return runOrDiagnose(args, { account });
34914
35304
  });
34915
35305
  server.registerTool("gog_chat_dm_send", {
34916
- description: "Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send." + workspaceOnlyNote,
35306
+ description: "Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send. Asks the MCP host to show the user a confirmation prompt with the recipient and text first; nothing is sent unless they accept." + CONFIRM_FALLBACK_DESCRIPTION + workspaceOnlyNote,
34917
35307
  annotations: { destructiveHint: true },
34918
35308
  inputSchema: external_exports.object({
34919
35309
  email: external_exports.string().describe("Recipient email address"),
34920
35310
  text: external_exports.string().describe("Message text"),
34921
35311
  thread: threadParam,
34922
- account: accountParam
35312
+ account: accountParam,
35313
+ confirmToken: confirmTokenParam
34923
35314
  })
34924
- }, async ({ email: email3, text, thread, account }) => {
35315
+ }, async ({ email: email3, text, thread, account, confirmToken }, ctx) => {
34925
35316
  const args = ["chat", "dm", "send", pos(email3), `--text=${text}`];
34926
35317
  if (thread) args.push(`--thread=${thread}`);
35318
+ const confirmation = await requireDispatchConfirmation(ctx, {
35319
+ ...CHAT_CONFIRM,
35320
+ action: "chat.dm-send",
35321
+ details: { to: email3, thread, textPreview: bodyPreview(text) },
35322
+ fallback: {
35323
+ tool: "gog_chat_dm_send",
35324
+ account,
35325
+ confirmToken,
35326
+ subject: () => {
35327
+ const from = senderPreview(account);
35328
+ return { target: email3, payload: { from, to: email3, thread, text }, preview: { from, to: email3, thread, text } };
35329
+ }
35330
+ }
35331
+ });
35332
+ if (confirmation) return confirmation;
34927
35333
  return runOrDiagnose(args, { account });
34928
35334
  });
34929
35335
  server.registerTool("gog_chat_dm_space", {
@@ -34978,11 +35384,45 @@ function registerChatTools(server) {
34978
35384
  registerRunTool(server, {
34979
35385
  service: "chat",
34980
35386
  examples: '"spaces", "messages", "dm"',
35387
+ vet: vetChatRun,
34981
35388
  note: "Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes."
34982
35389
  });
34983
35390
  }
34984
35391
 
34985
35392
  // src/tools/classroom.ts
35393
+ var CLASSROOM_CREATE_WORDS = /* @__PURE__ */ new Set(["create", "add", "new"]);
35394
+ var CLASSROOM_GATED = {
35395
+ announcements: { does: "posts to a class", tool: "gog_classroom_announcements_create" },
35396
+ announcement: { does: "posts to a class", tool: "gog_classroom_announcements_create" },
35397
+ ann: { does: "posts to a class", tool: "gog_classroom_announcements_create" },
35398
+ invitations: { does: "invites someone to a class", tool: "gog_classroom_invitations_create" },
35399
+ invitation: { does: "invites someone to a class", tool: "gog_classroom_invitations_create" },
35400
+ invites: { does: "invites someone to a class", tool: "gog_classroom_invitations_create" }
35401
+ };
35402
+ function vetClassroomRun(subcommand, args) {
35403
+ const sub = subcommand.toLowerCase();
35404
+ const gated = Object.hasOwn(CLASSROOM_GATED, sub) ? CLASSROOM_GATED[sub] : void 0;
35405
+ if (!gated) return void 0;
35406
+ const word = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
35407
+ return word ? gatedElsewhere(`gog classroom ${sub} ${word.toLowerCase()}`, "gog_classroom_run", gated.does, gated.tool) : void 0;
35408
+ }
35409
+ async function readCourse(courseId, account, runner = runOrDiagnose) {
35410
+ const got = await runner(["classroom", "courses", "get", pos(courseId)], { account });
35411
+ if (got.isError) return { error: got };
35412
+ let course;
35413
+ try {
35414
+ course = JSON.parse(resultText(got))?.course;
35415
+ } catch {
35416
+ course = void 0;
35417
+ }
35418
+ return {
35419
+ course: {
35420
+ id: courseId,
35421
+ ...typeof course?.name === "string" ? { name: course.name } : {},
35422
+ ...typeof course?.section === "string" ? { section: course.section } : {}
35423
+ }
35424
+ };
35425
+ }
34986
35426
  function registerClassroomTools(server) {
34987
35427
  server.registerTool("gog_classroom_courses_list", {
34988
35428
  description: "List Google Classroom courses.",
@@ -35265,19 +35705,42 @@ function registerClassroomTools(server) {
35265
35705
  return runOrDiagnose(["classroom", "announcements", "get", pos(courseId), pos(announcementId)], { account });
35266
35706
  });
35267
35707
  server.registerTool("gog_classroom_announcements_create", {
35268
- description: "Create an announcement in a Google Classroom course.",
35708
+ description: "Create an announcement in a Google Classroom course. Unless state is DRAFT (which students cannot see), this reads the course and asks the MCP host to show the user a confirmation prompt with the class, the full text and when it publishes; nothing is posted unless they accept. To stage one without asking, pass state DRAFT." + CONFIRM_FALLBACK_DESCRIPTION,
35269
35709
  annotations: { destructiveHint: true },
35270
35710
  inputSchema: external_exports.object({
35271
35711
  courseId: external_exports.string().describe("Course ID"),
35272
35712
  text: external_exports.string().describe("Announcement text"),
35273
- state: external_exports.enum(["PUBLISHED", "DRAFT"]).optional().describe("State"),
35713
+ state: external_exports.enum(["PUBLISHED", "DRAFT"]).optional().describe("State (DRAFT is visible only to teachers and needs no confirmation)"),
35274
35714
  scheduled: external_exports.string().optional().describe("Scheduled publish time"),
35275
- account: accountParam
35715
+ account: accountParam,
35716
+ confirmToken: confirmTokenParam
35276
35717
  })
35277
- }, async ({ courseId, text, state, scheduled, account }) => {
35718
+ }, async ({ courseId, text, state, scheduled, account, confirmToken }, ctx) => {
35278
35719
  const args = ["classroom", "announcements", "create", pos(courseId), `--text=${text}`];
35279
35720
  if (state) args.push(`--state=${state}`);
35280
35721
  if (scheduled) args.push(`--scheduled=${scheduled}`);
35722
+ if (state !== "DRAFT") {
35723
+ const read = await readCourse(courseId, account);
35724
+ if (read.error) return read.error;
35725
+ const publishes = scheduled ? `at ${scheduled}` : "immediately";
35726
+ const confirmation = await requireDispatchConfirmation(ctx, {
35727
+ action: "classroom.announcement-create",
35728
+ message: "Review and confirm this Classroom announcement:",
35729
+ confirmationLabel: "Confirm that this announcement should be posted to the class.",
35730
+ details: { course: read.course, publishes, textPreview: bodyPreview(text) },
35731
+ unsupportedNote: "Create it with state DRAFT instead; the user can review and post it from Classroom.",
35732
+ fallback: {
35733
+ tool: "gog_classroom_announcements_create",
35734
+ account,
35735
+ confirmToken,
35736
+ subject: () => {
35737
+ const view = { course: read.course, publishes, text };
35738
+ return { target: courseId, payload: view, preview: view };
35739
+ }
35740
+ }
35741
+ });
35742
+ if (confirmation) return confirmation;
35743
+ }
35281
35744
  return runOrDiagnose(args, { account });
35282
35745
  });
35283
35746
  server.registerTool("gog_classroom_topics_list", {
@@ -35367,6 +35830,7 @@ function registerClassroomTools(server) {
35367
35830
  registerRunTool(server, {
35368
35831
  service: "classroom",
35369
35832
  examples: '"guardians", "materials", "guardian-invitations"',
35833
+ vet: vetClassroomRun,
35370
35834
  note: "Covers anything not wrapped by the dedicated tools (guardians, guardian-invitations, materials, coursework assignees, announcement assignees, etc.)."
35371
35835
  });
35372
35836
  }
@@ -35527,7 +35991,18 @@ function registerDocsTools(server) {
35527
35991
  }
35528
35992
 
35529
35993
  // src/tools/drive.ts
35994
+ function vetDriveRun(subcommand, _args) {
35995
+ return subcommand.toLowerCase() === "share" ? gatedElsewhere("gog drive share", "gog_drive_run", "grants access to a file", "gog_drive_share") : void 0;
35996
+ }
35530
35997
  var GOOGLE_DOC_MIME = "application/vnd.google-apps.document";
35998
+ function shareTargetMeta(raw) {
35999
+ try {
36000
+ const { name, mimeType } = fileMeta(raw);
36001
+ return { name, mimeType };
36002
+ } catch {
36003
+ return {};
36004
+ }
36005
+ }
35531
36006
  function fileMeta(raw) {
35532
36007
  const parsed = JSON.parse(raw);
35533
36008
  const f = parsed.file ?? parsed;
@@ -35650,7 +36125,7 @@ function registerDriveTools(server) {
35650
36125
  return runOrDiagnose(args, { account });
35651
36126
  });
35652
36127
  server.registerTool("gog_drive_share", {
35653
- description: "Share a Google Drive file or folder.",
36128
+ description: "Share a Google Drive file or folder. Granting access is the risk even though no email is sent (gog does not notify by default), so this reads the file and asks the MCP host to show the user a confirmation prompt naming it, who gets access and with what role; nothing is shared unless they accept." + CONFIRM_FALLBACK_DESCRIPTION,
35654
36129
  annotations: { destructiveHint: true },
35655
36130
  inputSchema: external_exports.object({
35656
36131
  fileId: external_exports.string().describe("File or folder ID"),
@@ -35658,9 +36133,35 @@ function registerDriveTools(server) {
35658
36133
  email: external_exports.string().optional().describe("User email (required when to=user)"),
35659
36134
  domain: external_exports.string().optional().describe("Domain (required when to=domain)"),
35660
36135
  role: external_exports.enum(["reader", "writer"]).optional().describe("Permission role (default: reader)"),
35661
- account: accountParam
36136
+ account: accountParam,
36137
+ confirmToken: confirmTokenParam
35662
36138
  })
35663
- }, async ({ fileId, to, email: email3, domain: domain2, role, account }) => {
36139
+ }, async ({ fileId, to, email: email3, domain: domain2, role, account, confirmToken }, ctx) => {
36140
+ const got = await runOrDiagnose(["drive", "get", pos(fileId)], { account });
36141
+ if (got.isError) return got;
36142
+ const file2 = { id: fileId, ...shareTargetMeta(resultText(got)) };
36143
+ const grant = {
36144
+ file: file2,
36145
+ to,
36146
+ ...email3 ? { email: email3 } : {},
36147
+ ...domain2 ? { domain: domain2 } : {},
36148
+ role: role ?? "reader",
36149
+ publicLink: to === "anyone"
36150
+ };
36151
+ const confirmation = await requireDispatchConfirmation(ctx, {
36152
+ action: "drive.share",
36153
+ message: to === "anyone" ? "Review and confirm making this file available to ANYONE with the link:" : "Review and confirm this Drive share:",
36154
+ confirmationLabel: "Confirm that this access should be granted now.",
36155
+ details: grant,
36156
+ unsupportedNote: "Ask the user to share it themselves from Google Drive.",
36157
+ fallback: {
36158
+ tool: "gog_drive_share",
36159
+ account,
36160
+ confirmToken,
36161
+ subject: () => ({ target: fileId, payload: grant, preview: grant })
36162
+ }
36163
+ });
36164
+ if (confirmation) return confirmation;
35664
36165
  const args = ["drive", "share", pos(fileId), `--to=${to}`];
35665
36166
  if (email3) args.push(`--email=${email3}`);
35666
36167
  if (domain2) args.push(`--domain=${domain2}`);
@@ -35773,7 +36274,7 @@ function registerDriveTools(server) {
35773
36274
  return diagnose(err);
35774
36275
  }
35775
36276
  });
35776
- registerRunTool(server, { service: "drive", examples: '"copy", "download", "permissions"' });
36277
+ registerRunTool(server, { service: "drive", examples: '"copy", "download", "permissions"', vet: vetDriveRun });
35777
36278
  }
35778
36279
 
35779
36280
  // src/gmail-results.ts
@@ -35899,24 +36400,16 @@ var STAGING_TWIN = {
35899
36400
  var UNSUPPORTED_NOTE2 = {
35900
36401
  "gmail.drafts-send": "The draft is still saved: ask the user to review it and send it from Gmail."
35901
36402
  };
35902
- var BODY_PREVIEW_MAX = 2048;
35903
- function bodyPreview(text) {
35904
- if (!text) return void 0;
35905
- if (text.length <= BODY_PREVIEW_MAX) return text;
35906
- return `${text.slice(0, BODY_PREVIEW_MAX)}\u2026 [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
35907
- }
35908
- function attachmentNames(paths, inline) {
35909
- return [...paths ?? [], ...(inline ?? []).map((a) => a.filename)];
35910
- }
35911
- function requireGmailDispatchConfirmation(ctx, op, details) {
36403
+ async function requireGmailDispatchConfirmation(ctx, op, details, fallback) {
35912
36404
  const twin = STAGING_TWIN[op];
35913
36405
  const note = twin ? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail (gog_gmail_drafts_send also asks for confirmation).` : UNSUPPORTED_NOTE2[op];
35914
- return requireConfirmation(ctx, {
36406
+ return requireDispatchConfirmation(ctx, {
35915
36407
  action: op,
35916
36408
  message: op === "gmail.filter-forward" ? "Review and confirm this mail-forwarding filter:" : "Review and confirm this email dispatch:",
35917
36409
  details,
35918
36410
  confirmationLabel: op === "gmail.filter-forward" ? "Confirm that matching mail should be forwarded automatically from now on." : "Confirm that this email should be sent now.",
35919
- ...note ? { unsupportedNote: note } : {}
36411
+ unsupportedNote: note,
36412
+ ...fallback ? { fallback: { instruction: CONFIRM_SEND_INSTRUCTION, ...fallback } } : {}
35920
36413
  });
35921
36414
  }
35922
36415
  var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
@@ -35970,10 +36463,6 @@ function logGmailDispatch(tool, recipients, account) {
35970
36463
  process.stderr.write(`${JSON.stringify(event)}
35971
36464
  `);
35972
36465
  }
35973
- function resultText(result) {
35974
- const first = result.content[0];
35975
- return first && first.type === "text" && typeof first.text === "string" ? first.text : "{}";
35976
- }
35977
36466
 
35978
36467
  // src/tools/gmail.ts
35979
36468
  var replySchema = {
@@ -36021,7 +36510,7 @@ function appendReplyFlags(args, f) {
36021
36510
  if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
36022
36511
  args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
36023
36512
  }
36024
- var sendReplySchema = external_exports.object(replySchema);
36513
+ var sendReplySchema = external_exports.object({ ...replySchema, confirmToken: confirmTokenParam });
36025
36514
  function parseMetadataHeaders(raw) {
36026
36515
  let parsed;
36027
36516
  try {
@@ -36046,12 +36535,69 @@ function computeReplyRecipients(kind, headers, flags) {
36046
36535
  }
36047
36536
  return emails;
36048
36537
  }
36049
- async function sendReply(kind, toolName, messageId, account, flags, ctx) {
36538
+ function metadataThreadId(raw) {
36539
+ try {
36540
+ const threadId = JSON.parse(raw)?.message?.threadId;
36541
+ return typeof threadId === "string" ? threadId : void 0;
36542
+ } catch {
36543
+ return void 0;
36544
+ }
36545
+ }
36546
+ function replyTokenSubject(kind, messageId, account, headers, threadId, flags) {
36547
+ const removed = new Set(extractEmails(...flags.remove ?? []));
36548
+ const keep = (emails) => emails.filter((e) => !removed.has(e));
36549
+ const to = keep(extractEmails(headers.from, ...kind === "reply-all" ? [headers.to] : [], ...flags.to ?? []));
36550
+ const cc = keep(extractEmails(...kind === "reply-all" ? [headers.cc] : [], ...flags.cc ?? []));
36551
+ const bcc = keep(extractEmails(...flags.bcc ?? []));
36552
+ const attachments = attachmentDetails(flags.attach, flags.attachInline);
36553
+ const subject = flags.subject || (headers.subject ? `Re: ${headers.subject}` : void 0);
36554
+ const from = flags.from ?? (flags.autoFromAddressedAlias ? "the send-as alias the original was addressed to" : senderPreview(account));
36555
+ const localFiles = attachmentDetails([flags.bodyHtmlFile, flags.signatureFile].filter((p) => Boolean(p)), void 0);
36556
+ return {
36557
+ target: messageId,
36558
+ payload: {
36559
+ kind,
36560
+ to,
36561
+ cc,
36562
+ bcc,
36563
+ subject,
36564
+ from,
36565
+ body: flags.body,
36566
+ bodyHtml: flags.bodyHtml,
36567
+ localFiles,
36568
+ signature: Boolean(flags.signature),
36569
+ signatureFrom: flags.signatureFrom,
36570
+ attachments,
36571
+ quote: !flags.noQuote,
36572
+ threadId,
36573
+ inReplyTo: headers.message_id,
36574
+ references: headers.references
36575
+ },
36576
+ preview: {
36577
+ from,
36578
+ to,
36579
+ cc,
36580
+ bcc,
36581
+ subject,
36582
+ body: flags.body,
36583
+ ...flags.bodyHtml ? { bodyHtml: flags.bodyHtml } : {},
36584
+ ...flags.bodyHtmlFile ? { bodyHtmlFile: flags.bodyHtmlFile } : {},
36585
+ ...flags.signature || flags.signatureFrom || flags.signatureFile ? { signature: flags.signatureFile ?? flags.signatureFrom ?? "the Gmail signature of the sending address" } : {},
36586
+ quotesOriginal: !flags.noQuote,
36587
+ attachments: attachmentPreview(attachments),
36588
+ replyingToMessageId: messageId,
36589
+ threadId,
36590
+ inReplyTo: headers.message_id
36591
+ }
36592
+ };
36593
+ }
36594
+ async function sendReply(kind, toolName, messageId, account, flags, ctx, confirmToken) {
36050
36595
  const metaResult = await runOrDiagnose(["gmail", "get", pos(messageId), "--format=metadata"], { account });
36051
36596
  if (metaResult.isError) return metaResult;
36052
- const headers = parseMetadataHeaders(resultText(metaResult));
36597
+ const metaText = resultText(metaResult);
36598
+ const headers = parseMetadataHeaders(metaText);
36053
36599
  const recipients = computeReplyRecipients(kind, headers, flags);
36054
- const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
36600
+ const confirmation = await requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
36055
36601
  messageId,
36056
36602
  recipients,
36057
36603
  recipientCount: recipients.length,
@@ -36062,6 +36608,13 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
36062
36608
  bodyHtmlFile: flags.bodyHtmlFile,
36063
36609
  attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
36064
36610
  attachments: attachmentNames(flags.attach, flags.attachInline)
36611
+ }, {
36612
+ tool: toolName,
36613
+ account,
36614
+ confirmToken,
36615
+ // The metadata read above IS the phase-2 re-read: it runs on every call,
36616
+ // so the original's Message-ID/References are fresh here.
36617
+ subject: () => replyTokenSubject(kind, messageId, account, headers, metadataThreadId(metaText), flags)
36065
36618
  });
36066
36619
  if (confirmation) return confirmation;
36067
36620
  const args = ["gmail", kind, pos(messageId)];
@@ -36079,7 +36632,7 @@ function vetGmailRun(subcommand, args) {
36079
36632
  return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
36080
36633
  }
36081
36634
  if (subcommand === "settings") {
36082
- const blocked = args.find((a) => GMAIL_RUN_BLOCKED_SETTINGS.has(a.toLowerCase()));
36635
+ const blocked = hasCommandWord(args, GMAIL_RUN_BLOCKED_SETTINGS);
36083
36636
  if (blocked) {
36084
36637
  return `gog gmail settings ${blocked} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
36085
36638
  }
@@ -36138,7 +36691,7 @@ function registerGmailTools(server) {
36138
36691
  return runOrDiagnose(args, { account });
36139
36692
  });
36140
36693
  server.registerTool("gog_gmail_send", {
36141
- description: 'SENDS MAIL \u2014 asks the MCP host to show a confirmation prompt with the recipients, subject, a preview of the body and the attachment names. Mail is sent only after the user accepts that prompt. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded. NOT the tool for answering a message: replyToMessageId only files this in the right thread \u2014 the subject, recipients and body are entirely yours, and the original is not quoted unless you set quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
36694
+ description: 'SENDS MAIL \u2014 asks the MCP host to show a confirmation prompt with the recipients, subject, a preview of the body and the attachment names. Mail is sent only after the user accepts that prompt. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded. NOT the tool for answering a message: replyToMessageId only files this in the right thread \u2014 the subject, recipients and body are entirely yours, and the original is not quoted unless you set quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.' + CONFIRM_FALLBACK_DESCRIPTION,
36142
36695
  annotations: { destructiveHint: true },
36143
36696
  inputSchema: external_exports.object({
36144
36697
  to: external_exports.string().describe("Recipient(s), comma-separated"),
@@ -36151,9 +36704,10 @@ function registerGmailTools(server) {
36151
36704
  quote: external_exports.boolean().optional().describe("Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it."),
36152
36705
  attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment. Must be inside the server's GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).`),
36153
36706
  attachInline: attachInlineParam,
36154
- account: accountParam
36707
+ account: accountParam,
36708
+ confirmToken: confirmTokenParam
36155
36709
  })
36156
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
36710
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account, confirmToken }, ctx) => {
36157
36711
  confinePaths(attach, "attach");
36158
36712
  const args = ["gmail", "send", `--to=${to}`, `--subject=${subject}`, payloadArg("body", "body-file", body)];
36159
36713
  if (cc) args.push(`--cc=${cc}`);
@@ -36165,7 +36719,7 @@ function registerGmailTools(server) {
36165
36719
  const inline = inlineAttachmentArgs("attach", attachInline, args);
36166
36720
  args.push(...inline);
36167
36721
  const recipients = extractEmails(to, cc, bcc);
36168
- const confirmation = requireGmailDispatchConfirmation(ctx, "gmail.send", {
36722
+ const confirmation = await requireGmailDispatchConfirmation(ctx, "gmail.send", {
36169
36723
  to,
36170
36724
  cc,
36171
36725
  bcc,
@@ -36178,6 +36732,32 @@ function registerGmailTools(server) {
36178
36732
  quoting: Boolean(quote),
36179
36733
  attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
36180
36734
  attachments: attachmentNames(attach, attachInline)
36735
+ }, {
36736
+ tool: "gog_gmail_send",
36737
+ account,
36738
+ confirmToken,
36739
+ // Nothing is stored between the phases: the payload IS the arguments,
36740
+ // so phase 2 must repeat them and any difference is a changed send.
36741
+ subject: () => {
36742
+ const attachments = attachmentDetails(attach, attachInline);
36743
+ const from = senderPreview(account);
36744
+ return {
36745
+ target: replyToMessageId ?? threadId ?? "",
36746
+ payload: { from, to, cc, bcc, subject, body, attachments, threadId, inReplyTo: replyToMessageId, quote: Boolean(quote) },
36747
+ preview: {
36748
+ from,
36749
+ to,
36750
+ cc,
36751
+ bcc,
36752
+ subject,
36753
+ body,
36754
+ attachments: attachmentPreview(attachments),
36755
+ threadId,
36756
+ inReplyTo: replyToMessageId,
36757
+ quotesOriginal: Boolean(quote)
36758
+ }
36759
+ };
36760
+ }
36181
36761
  });
36182
36762
  if (confirmation) return confirmation;
36183
36763
  const result = await runOrDiagnose(args, { account });
@@ -36185,22 +36765,22 @@ function registerGmailTools(server) {
36185
36765
  return result;
36186
36766
  });
36187
36767
  server.registerTool("gog_gmail_reply", {
36188
- description: 'SENDS MAIL \u2014 asks the MCP host to show a confirmation prompt with the resolved recipient, subject, a preview of the body and the attachment names. Mail is sent only after the user accepts that prompt. To STAGE a reply instead of sending it, use gog_gmail_drafts_reply (gogcli-mcp-gmail only), which never needs confirmation. Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below yours, which gog_gmail_send does not \u2014 a send with replyToMessageId lands in the right thread but reads as a brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft instead of sending it.',
36768
+ description: 'SENDS MAIL \u2014 asks the MCP host to show a confirmation prompt with the resolved recipient, subject, a preview of the body and the attachment names. Mail is sent only after the user accepts that prompt. To STAGE a reply instead of sending it, use gog_gmail_drafts_reply (gogcli-mcp-gmail only), which never needs confirmation. Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below yours, which gog_gmail_send does not \u2014 a send with replyToMessageId lands in the right thread but reads as a brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft instead of sending it.' + CONFIRM_FALLBACK_DESCRIPTION,
36189
36769
  annotations: { destructiveHint: true },
36190
36770
  inputSchema: sendReplySchema
36191
- }, async ({ messageId, account, ...flags }, ctx) => {
36771
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
36192
36772
  assertNotBoth("bodyHtml", "bodyHtmlFile", flags.bodyHtml, flags.bodyHtmlFile);
36193
36773
  confineReplyPaths(flags);
36194
- return sendReply("reply", "gog_gmail_reply", messageId, account, flags, ctx);
36774
+ return sendReply("reply", "gog_gmail_reply", messageId, account, flags, ctx, confirmToken);
36195
36775
  });
36196
36776
  server.registerTool("gog_gmail_reply_all", {
36197
- description: 'SENDS MAIL \u2014 asks the MCP host to show a confirmation prompt with the resolved recipients, subject, a preview of the body and the attachment names. Mail is sent only after the user accepts that prompt. To STAGE a reply-all instead of sending it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only), which never needs confirmation. Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all.',
36777
+ description: 'SENDS MAIL \u2014 asks the MCP host to show a confirmation prompt with the resolved recipients, subject, a preview of the body and the attachment names. Mail is sent only after the user accepts that prompt. To STAGE a reply-all instead of sending it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only), which never needs confirmation. Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all.' + CONFIRM_FALLBACK_DESCRIPTION,
36198
36778
  annotations: { destructiveHint: true },
36199
36779
  inputSchema: sendReplySchema
36200
- }, async ({ messageId, account, ...flags }, ctx) => {
36780
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
36201
36781
  assertNotBoth("bodyHtml", "bodyHtmlFile", flags.bodyHtml, flags.bodyHtmlFile);
36202
36782
  confineReplyPaths(flags);
36203
- return sendReply("reply-all", "gog_gmail_reply_all", messageId, account, flags, ctx);
36783
+ return sendReply("reply-all", "gog_gmail_reply_all", messageId, account, flags, ctx, confirmToken);
36204
36784
  });
36205
36785
  registerRunTool(server, {
36206
36786
  service: "gmail",
@@ -36541,7 +37121,7 @@ function registerTasksTools(server) {
36541
37121
  }
36542
37122
 
36543
37123
  // src/server.ts
36544
- var VERSION = true ? "4.3.0" : "0.0.0";
37124
+ var VERSION = true ? "4.4.0" : "0.0.0";
36545
37125
  var BASE_TOOL_REGISTRARS = [
36546
37126
  registerApiTools,
36547
37127
  registerAppScriptTools,
@@ -36593,12 +37173,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
36593
37173
  const [clientId, clientSecret, refreshToken, account] = values;
36594
37174
  const { spawner } = options;
36595
37175
  try {
36596
- const { createHash: createHash2 } = await import("node:crypto");
37176
+ const { createHash: createHash4 } = await import("node:crypto");
36597
37177
  const { mkdir, readFile, writeFile, chmod } = await import("node:fs/promises");
36598
37178
  const home = options.home ?? (await import("node:os")).homedir();
36599
37179
  const dir = join5(home, ".gogcli-mcp");
36600
37180
  const marker = join5(dir, AUTH_BOOTSTRAP_MARKER);
36601
- const fingerprint = createHash2("sha256").update(values.join("\0")).digest("hex");
37181
+ const fingerprint = createHash4("sha256").update(values.join("\0")).digest("hex");
36602
37182
  const previous = await readFile(marker, "utf8").catch(() => void 0);
36603
37183
  if (previous === fingerprint && await accountListed(account, spawner)) return "present";
36604
37184
  await run(
@@ -36623,7 +37203,7 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
36623
37203
  }
36624
37204
 
36625
37205
  // src/blob-urls.ts
36626
- import { createHmac as createHmac2 } from "node:crypto";
37206
+ import { createHmac as createHmac3 } from "node:crypto";
36627
37207
  var BLOB_URL_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
36628
37208
  var BLOB_URL_CEILING_MARGIN_MS = 60 * 1e3;
36629
37209
  var BLOB_URL_DEFAULT_TTL_MS = 60 * 60 * 1e3;
@@ -36635,8 +37215,8 @@ ${exp}`;
36635
37215
  function writePayload(objectKey, contentType, exp) {
36636
37216
  return `put\0${objectKey}\0${contentType}\0${exp}`;
36637
37217
  }
36638
- function sign(signingKey, payload) {
36639
- return createHmac2("sha256", signingKey).update(payload, "utf8").digest("base64url");
37218
+ function sign2(signingKey, payload) {
37219
+ return createHmac3("sha256", signingKey).update(payload, "utf8").digest("base64url");
36640
37220
  }
36641
37221
  function objectPathSegments(rest) {
36642
37222
  const segments = rest.split("/");
@@ -36672,7 +37252,7 @@ function createBlobUrlMinter(config2) {
36672
37252
  function mint(rest, exp, payload) {
36673
37253
  const segments = objectPathSegments(rest);
36674
37254
  const url2 = `${basePrefix}/${segments.map(encodeURIComponent).join("/")}`;
36675
- const signature = sign(config2.signingKey, payload(`${registrationId}/${segments.join("/")}`));
37255
+ const signature = sign2(config2.signingKey, payload(`${registrationId}/${segments.join("/")}`));
36676
37256
  return `${url2}?exp=${exp}&sig=${signature}`;
36677
37257
  }
36678
37258
  return {
@@ -36821,6 +37401,7 @@ export {
36821
37401
  BLOB_UPLOAD_TIMEOUT_MS,
36822
37402
  BLOB_URL_DEFAULT_TTL_MS,
36823
37403
  BLOB_URL_MAX_TTL_MS,
37404
+ CONFIRM_FALLBACK_DESCRIPTION,
36824
37405
  FILE_ROOTS_ENV,
36825
37406
  INLINE_ATTACHMENT_LIMITS_TEXT,
36826
37407
  MAX_BLOB_UPLOAD_BYTES,
@@ -36835,7 +37416,9 @@ export {
36835
37416
  appendReplyFlags,
36836
37417
  assertNotBoth,
36837
37418
  attachInlineParam,
37419
+ attachmentDetails,
36838
37420
  attachmentNames,
37421
+ attachmentPreview,
36839
37422
  authToolsFor,
36840
37423
  blobStoreFromEnv,
36841
37424
  bodyPreview,
@@ -36843,6 +37426,7 @@ export {
36843
37426
  confineAtFile,
36844
37427
  confinePath,
36845
37428
  confinePaths,
37429
+ confirmTokenParam,
36846
37430
  createBlobUrlMinter,
36847
37431
  defaultFileRoot,
36848
37432
  diagnose,
@@ -36865,6 +37449,7 @@ export {
36865
37449
  pos,
36866
37450
  prepareDownloadRoot,
36867
37451
  pushPaginationFlags,
37452
+ readCourse,
36868
37453
  registerApiTools,
36869
37454
  registerAppScriptTools,
36870
37455
  registerAuthTools,
@@ -36881,12 +37466,14 @@ export {
36881
37466
  registerTasksTools,
36882
37467
  removeDownload,
36883
37468
  replySchema,
37469
+ requireDispatchConfirmation,
36884
37470
  requireGmailDispatchConfirmation,
36885
37471
  resolvePageToken,
36886
37472
  resultText,
36887
37473
  run,
36888
37474
  runBinary,
36889
37475
  runOrDiagnose,
37476
+ senderPreview,
36890
37477
  stripConsumedPageToken,
36891
37478
  uploadToBlobStore
36892
37479
  };