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/index.js CHANGED
@@ -34206,14 +34206,14 @@ function serveStdio(factory, options = {}) {
34206
34206
  return false;
34207
34207
  };
34208
34208
  const answerLegacyRejection = (request, reason, requestedVersion) => {
34209
- const rejection2 = modernOnlyStrictRejection({
34209
+ const rejection3 = modernOnlyStrictRejection({
34210
34210
  kind: "legacy",
34211
34211
  reason,
34212
34212
  ...requestedVersion !== void 0 && { requestedVersion }
34213
34213
  }, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
34214
- if (rejection2 === void 0) return Promise.resolve();
34215
- reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection2.cell}): ${rejection2.message}`));
34216
- return writeErrorResponse(request.id, rejection2.code, rejection2.message, rejection2.data);
34214
+ if (rejection3 === void 0) return Promise.resolve();
34215
+ reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection3.cell}): ${rejection3.message}`));
34216
+ return writeErrorResponse(request.id, rejection3.code, rejection3.message, rejection3.data);
34217
34217
  };
34218
34218
  const onInstanceClosed = (channel) => {
34219
34219
  if (closing || channel === discarding) return;
@@ -34995,9 +34995,9 @@ function offsetAt(instant, tz) {
34995
34995
  const w = wallPartsIn(instant, tz);
34996
34996
  const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
34997
34997
  const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
34998
- const sign = minutes < 0 ? "-" : "+";
34998
+ const sign2 = minutes < 0 ? "-" : "+";
34999
34999
  const abs = Math.abs(minutes);
35000
- return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
35000
+ return `${sign2}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
35001
35001
  }
35002
35002
  function wallPartsIn(instant, tz) {
35003
35003
  const parts = new Intl.DateTimeFormat("en-US", {
@@ -35843,13 +35843,239 @@ function assertNotBoth(inlineParam, fileParam, inlineValue, fileValue) {
35843
35843
  }
35844
35844
  }
35845
35845
 
35846
+ // src/dispatch-confirmation.ts
35847
+ import { createHash as createHash3 } from "node:crypto";
35848
+ import { readFileSync } from "node:fs";
35849
+
35850
+ // src/send-confirm-token.ts
35851
+ import { createHash as createHash2, createHmac as createHmac2, randomBytes, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
35852
+ var CONFIRM_TOKEN_TTL_DEFAULT_SECONDS = 600;
35853
+ var PREFIX = "gct1";
35854
+ var secret;
35855
+ var spent = /* @__PURE__ */ new Map();
35856
+ function sendConfirmFallbackEnabled() {
35857
+ return readEnvVar("GOG_SEND_CONFIRM_FALLBACK")?.trim().toLowerCase() === "token";
35858
+ }
35859
+ function confirmTokenTtlSeconds() {
35860
+ const raw = readEnvVar("GOG_CONFIRM_TTL_SECONDS")?.trim();
35861
+ if (raw && /^\d+$/.test(raw)) {
35862
+ const n = Number(raw);
35863
+ if (n > 0) return n;
35864
+ }
35865
+ return CONFIRM_TOKEN_TTL_DEFAULT_SECONDS;
35866
+ }
35867
+ function hmacKey() {
35868
+ if (!secret) {
35869
+ const configured = readEnvVar("GOG_CONFIRM_SECRET");
35870
+ secret = configured ? Buffer.from(configured, "utf8") : randomBytes(32);
35871
+ }
35872
+ return secret;
35873
+ }
35874
+ function canonicalize(value) {
35875
+ if (Array.isArray(value)) return value.map(canonicalize);
35876
+ if (value && typeof value === "object") {
35877
+ const out = {};
35878
+ for (const key of Object.keys(value).sort()) {
35879
+ const v = value[key];
35880
+ if (v !== void 0) out[key] = canonicalize(v);
35881
+ }
35882
+ return out;
35883
+ }
35884
+ return value;
35885
+ }
35886
+ function hashSendPayload(payload) {
35887
+ return createHash2("sha256").update(JSON.stringify(canonicalize(payload))).digest("hex");
35888
+ }
35889
+ function sign(body) {
35890
+ return createHmac2("sha256", hmacKey()).update(`${PREFIX}.${body}`).digest("base64url");
35891
+ }
35892
+ function issueConfirmToken(binding, now = Date.now()) {
35893
+ const exp = now + confirmTokenTtlSeconds() * 1e3;
35894
+ const claims = {
35895
+ t: binding.tool,
35896
+ a: binding.account,
35897
+ g: binding.target,
35898
+ ...binding.revision === void 0 ? {} : { r: binding.revision },
35899
+ h: binding.payloadHash,
35900
+ iat: now,
35901
+ exp,
35902
+ n: randomBytes(16).toString("base64url")
35903
+ };
35904
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
35905
+ return { token: `${PREFIX}.${body}.${sign(body)}`, expiresAt: new Date(exp).toISOString() };
35906
+ }
35907
+ function signatureMatches(body, sig) {
35908
+ const expected = Buffer.from(sign(body), "utf8");
35909
+ const actual = Buffer.from(sig, "utf8");
35910
+ return expected.length === actual.length && timingSafeEqual2(expected, actual);
35911
+ }
35912
+ function parseClaims(body) {
35913
+ try {
35914
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
35915
+ } catch {
35916
+ return void 0;
35917
+ }
35918
+ }
35919
+ function verifyConfirmToken(token, binding, now = Date.now()) {
35920
+ for (const [nonce, exp] of spent) if (exp < now) spent.delete(nonce);
35921
+ const parts = token.split(".");
35922
+ if (parts.length !== 3 || parts[0] !== PREFIX || !parts[1] || !parts[2]) return { ok: false, error: "TOKEN_INVALID" };
35923
+ const [, body, sig] = parts;
35924
+ if (!signatureMatches(body, sig)) return { ok: false, error: "TOKEN_INVALID" };
35925
+ const claims = parseClaims(body);
35926
+ if (!claims) return { ok: false, error: "TOKEN_INVALID" };
35927
+ if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
35928
+ return { ok: false, error: "TOKEN_INVALID" };
35929
+ }
35930
+ if (spent.has(claims.n)) return { ok: false, error: "TOKEN_REUSED" };
35931
+ if (now > claims.exp) return { ok: false, error: "TOKEN_EXPIRED" };
35932
+ if (claims.r !== binding.revision) return { ok: false, error: "DRAFT_CHANGED", reason: "message-id-rotated" };
35933
+ if (claims.h !== binding.payloadHash) return { ok: false, error: "DRAFT_CHANGED", reason: "payload-changed" };
35934
+ spent.set(claims.n, claims.exp);
35935
+ return { ok: true };
35936
+ }
35937
+
35938
+ // src/dispatch-confirmation.ts
35939
+ var BODY_PREVIEW_MAX = 2048;
35940
+ function bodyPreview(text) {
35941
+ if (!text) return void 0;
35942
+ if (text.length <= BODY_PREVIEW_MAX) return text;
35943
+ return `${text.slice(0, BODY_PREVIEW_MAX)}\u2026 [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
35944
+ }
35945
+ function attachmentNames(paths, inline) {
35946
+ return [...paths ?? [], ...(inline ?? []).map((a) => a.filename)];
35947
+ }
35948
+ function attachmentDetails(paths, inline) {
35949
+ const out = [];
35950
+ for (const path of paths ?? []) {
35951
+ let bytes;
35952
+ try {
35953
+ bytes = readFileSync(path);
35954
+ } catch {
35955
+ bytes = void 0;
35956
+ }
35957
+ out.push(bytes ? { name: path, size: bytes.length, sha256: createHash3("sha256").update(bytes).digest("hex") } : { name: path, size: null });
35958
+ }
35959
+ for (const a of inline ?? []) {
35960
+ out.push({
35961
+ name: a.filename,
35962
+ size: Buffer.from(a.contentBase64, "base64").length,
35963
+ sha256: createHash3("sha256").update(a.contentBase64).digest("hex")
35964
+ });
35965
+ }
35966
+ return out;
35967
+ }
35968
+ function senderPreview(account, from) {
35969
+ return from ?? account ?? readEnvVar("GOG_ACCOUNT") ?? "the gog account's default address";
35970
+ }
35971
+ function attachmentPreview(details) {
35972
+ return details.map(({ name, size }) => ({ name, size }));
35973
+ }
35974
+ 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.";
35975
+ 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.";
35976
+ var FALLBACK_HINT = "Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.";
35977
+ 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.';
35978
+ var confirmTokenParam = external_exports.string().optional().describe(
35979
+ `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.`
35980
+ );
35981
+ var TOKEN_ERROR_NOTE = {
35982
+ 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.",
35983
+ 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.",
35984
+ 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."
35985
+ };
35986
+ var DRAFT_CHANGED_NOTE = {
35987
+ "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.",
35988
+ "payload-changed": "Nothing was sent or changed: what would happen no longer matches what the user approved."
35989
+ };
35990
+ function isToolResult(value) {
35991
+ return Array.isArray(value.content);
35992
+ }
35993
+ function rejection2(data) {
35994
+ return { ...textResult({ status: "confirmation-rejected", confirmed: false, dispatched: false, ...data }), isError: true };
35995
+ }
35996
+ async function tokenConfirmation(op, fallback) {
35997
+ const subject = await fallback.subject();
35998
+ if (isToolResult(subject)) return subject;
35999
+ const binding = {
36000
+ tool: fallback.tool,
36001
+ account: fallback.account ?? readEnvVar("GOG_ACCOUNT") ?? "",
36002
+ target: subject.target,
36003
+ ...subject.revision === void 0 ? {} : { revision: subject.revision },
36004
+ payloadHash: hashSendPayload(subject.payload)
36005
+ };
36006
+ const phaseOne = () => {
36007
+ const { token, expiresAt } = issueConfirmToken(binding);
36008
+ return {
36009
+ action: op,
36010
+ preview: subject.preview,
36011
+ confirmToken: token,
36012
+ expiresAt,
36013
+ ttlSeconds: confirmTokenTtlSeconds(),
36014
+ instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION
36015
+ };
36016
+ };
36017
+ if (!fallback.confirmToken) {
36018
+ return textResult({ status: "confirmation-required", confirmed: false, dispatched: false, ...phaseOne() });
36019
+ }
36020
+ const verdict = verifyConfirmToken(fallback.confirmToken, binding);
36021
+ if (verdict.ok) return void 0;
36022
+ if (verdict.error === "DRAFT_CHANGED") {
36023
+ return rejection2({
36024
+ error: "DRAFT_CHANGED",
36025
+ reason: verdict.reason,
36026
+ note: `${DRAFT_CHANGED_NOTE[verdict.reason]} The current preview and a fresh confirmToken are below.`,
36027
+ ...phaseOne()
36028
+ });
36029
+ }
36030
+ return rejection2({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
36031
+ }
36032
+ function gatedElsewhere(what, via, does, tool) {
36033
+ return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
36034
+ }
36035
+ function hasCommandWord(args, words) {
36036
+ return args.find((a) => words.has(a.toLowerCase()));
36037
+ }
36038
+ async function requireDispatchConfirmation(ctx, options) {
36039
+ const { action, fallback } = options;
36040
+ if (fallback && callerAcceptsFormElicitation(ctx) === false && sendConfirmFallbackEnabled()) {
36041
+ return tokenConfirmation(action, fallback);
36042
+ }
36043
+ const note = fallback ? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(" ") : options.unsupportedNote;
36044
+ return requireConfirmation(ctx, {
36045
+ action,
36046
+ message: options.message,
36047
+ details: options.details,
36048
+ confirmationLabel: options.confirmationLabel,
36049
+ ...note ? { unsupportedNote: note } : {}
36050
+ });
36051
+ }
36052
+ function resultText(result) {
36053
+ const first = result.content[0];
36054
+ return first && first.type === "text" && typeof first.text === "string" ? first.text : "{}";
36055
+ }
36056
+
35846
36057
  // src/tools/api.ts
35847
36058
  var GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
36059
+ var DISPATCH_API_BLOCKED = {
36060
+ chat: [{ method: /(?:^|\.)spaces\.messages\.create$/i, does: "posts a Chat message", tool: "gog_chat_messages_send / gog_chat_dm_send" }],
36061
+ drive: [{ method: /(?:^|\.)permissions\.(?:create|update)$/i, does: "grants access to a file", tool: "gog_drive_share" }],
36062
+ classroom: [
36063
+ { method: /(?:^|\.)courses\.announcements\.create$/i, does: "posts to a class", tool: "gog_classroom_announcements_create" },
36064
+ { method: /(?:^|\.)invitations\.create$/i, does: "invites someone to a class", tool: "gog_classroom_invitations_create" }
36065
+ ],
36066
+ calendar: [
36067
+ { method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: "can put an event on guests' calendars", tool: "gog_calendar_create" },
36068
+ { method: /(?:^|\.)events\.(?:update|patch)$/i, does: "can change what guests see", tool: "gog_calendar_update / gog_calendar_respond" }
36069
+ ]
36070
+ };
35848
36071
  function refusedApiCall(api, method) {
35849
- if (api.trim().toLowerCase() === "gmail" && GMAIL_API_BLOCKED.test(method.trim())) {
36072
+ const name = api.trim().toLowerCase();
36073
+ const m = method.trim();
36074
+ if (name === "gmail" && GMAIL_API_BLOCKED.test(m)) {
35850
36075
  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.`;
35851
36076
  }
35852
- return void 0;
36077
+ const hit = (Object.hasOwn(DISPATCH_API_BLOCKED, name) ? DISPATCH_API_BLOCKED[name] : []).find((b) => b.method.test(m));
36078
+ return hit ? gatedElsewhere(`${name} ${m}`, "gog_api_call", hit.does, hit.tool) : void 0;
35853
36079
  }
35854
36080
  function registerApiTools(server) {
35855
36081
  server.registerTool("gog_api_list", {
@@ -35879,7 +36105,7 @@ function registerApiTools(server) {
35879
36105
  return runOrDiagnose(args, { account });
35880
36106
  });
35881
36107
  server.registerTool("gog_api_call", {
35882
- 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.",
36108
+ 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.",
35883
36109
  annotations: { destructiveHint: true },
35884
36110
  inputSchema: external_exports.object({
35885
36111
  api: external_exports.string().describe("Discovery API name (e.g. drive, gmail, calendar)"),
@@ -36162,6 +36388,54 @@ function registerAuthTools(server) {
36162
36388
  }
36163
36389
 
36164
36390
  // src/tools/calendar.ts
36391
+ var CALENDAR_GATED = {
36392
+ create: "gog_calendar_create",
36393
+ add: "gog_calendar_create",
36394
+ new: "gog_calendar_create",
36395
+ update: "gog_calendar_update",
36396
+ edit: "gog_calendar_update",
36397
+ set: "gog_calendar_update",
36398
+ respond: "gog_calendar_respond",
36399
+ rsvp: "gog_calendar_respond",
36400
+ reply: "gog_calendar_respond"
36401
+ };
36402
+ function vetCalendarRun(subcommand, _args) {
36403
+ const sub = subcommand.toLowerCase();
36404
+ const tool = Object.hasOwn(CALENDAR_GATED, sub) ? CALENDAR_GATED[sub] : void 0;
36405
+ return tool ? gatedElsewhere(`gog calendar ${sub}`, "gog_calendar_run", "can change what guests see", tool) : void 0;
36406
+ }
36407
+ function parseAttendees(value) {
36408
+ if (!value) return [];
36409
+ 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);
36410
+ }
36411
+ function timeOf(t) {
36412
+ const v = t?.dateTime ?? t?.date;
36413
+ return typeof v === "string" ? v : void 0;
36414
+ }
36415
+ function eventSnapshot(raw) {
36416
+ let event;
36417
+ try {
36418
+ event = JSON.parse(raw)?.event;
36419
+ } catch {
36420
+ event = void 0;
36421
+ }
36422
+ const str = (v) => typeof v === "string" ? v : void 0;
36423
+ return {
36424
+ summary: str(event?.summary),
36425
+ start: timeOf(event?.start),
36426
+ end: timeOf(event?.end),
36427
+ organizer: str(event?.organizer?.email),
36428
+ // Guests are the OTHER people: not this account's own attendee row, not rooms.
36429
+ guests: (Array.isArray(event?.attendees) ? event.attendees : []).filter((a) => a.self !== true && a.resource !== true && typeof a.email === "string").map((a) => a.email),
36430
+ etag: str(event?.etag)
36431
+ };
36432
+ }
36433
+ async function readEvent(calendarId, eventId, account) {
36434
+ const got = await runOrDiagnose(["calendar", "event", pos(calendarId), pos(eventId)], { account });
36435
+ if (got.isError) return { error: got };
36436
+ return { event: eventSnapshot(resultText(got)) };
36437
+ }
36438
+ var CALENDAR_CONFIRM_LABEL = "Confirm that this change, which other people will see, should be made now.";
36165
36439
  var reminderParams = {
36166
36440
  reminders: external_exports.array(external_exports.string()).max(5).optional().describe(
36167
36441
  `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.`
@@ -36249,7 +36523,7 @@ function registerCalendarTools(server) {
36249
36523
  return runOrDiagnose(args, { account });
36250
36524
  });
36251
36525
  server.registerTool("gog_calendar_create", {
36252
- 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).",
36526
+ 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,
36253
36527
  annotations: { destructiveHint: false },
36254
36528
  inputSchema: external_exports.object({
36255
36529
  calendarId: external_exports.string().describe('Calendar ID (use "primary" for the default calendar)'),
@@ -36263,9 +36537,10 @@ function registerCalendarTools(server) {
36263
36537
  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."),
36264
36538
  withZoom: external_exports.boolean().optional().describe("Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)"),
36265
36539
  ...reminderParams,
36266
- account: accountParam
36540
+ account: accountParam,
36541
+ confirmToken: confirmTokenParam
36267
36542
  })
36268
- }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account }) => {
36543
+ }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account, confirmToken }, ctx) => {
36269
36544
  const args = ["calendar", "create", pos(calendarId), `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
36270
36545
  if (description) args.push(`--description=${description}`);
36271
36546
  if (location) args.push(`--location=${location}`);
@@ -36274,10 +36549,28 @@ function registerCalendarTools(server) {
36274
36549
  if (timezone) args.push(`--timezone=${timezone}`);
36275
36550
  if (withZoom) args.push("--with-zoom");
36276
36551
  pushReminderFlags(args, { reminders, noReminders });
36552
+ const guests = parseAttendees(attendees);
36553
+ if (guests.length > 0) {
36554
+ const event = { calendarId, summary, from, to, allDay: Boolean(allDay), timezone, location, description, guests, attendees, withZoom: Boolean(withZoom) };
36555
+ const confirmation = await requireDispatchConfirmation(ctx, {
36556
+ action: "calendar.create",
36557
+ message: "Review and confirm this event, which will appear on the guests' calendars:",
36558
+ confirmationLabel: CALENDAR_CONFIRM_LABEL,
36559
+ details: event,
36560
+ unsupportedNote: "Create it without attendees instead; the user can add the guests in Google Calendar.",
36561
+ fallback: {
36562
+ tool: "gog_calendar_create",
36563
+ account,
36564
+ confirmToken,
36565
+ subject: () => ({ target: calendarId, payload: event, preview: event })
36566
+ }
36567
+ });
36568
+ if (confirmation) return confirmation;
36569
+ }
36277
36570
  return runOrDiagnose(args, { account });
36278
36571
  });
36279
36572
  server.registerTool("gog_calendar_update", {
36280
- 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.",
36573
+ 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,
36281
36574
  annotations: { destructiveHint: false },
36282
36575
  inputSchema: external_exports.object({
36283
36576
  calendarId: external_exports.string().describe("Calendar ID"),
@@ -36295,9 +36588,10 @@ function registerCalendarTools(server) {
36295
36588
  removeZoom: external_exports.boolean().optional().describe("Remove the event's Zoom video conference"),
36296
36589
  removeMeet: external_exports.boolean().optional().describe("Remove the event's Google Meet video conference (clears conference data only)"),
36297
36590
  ...reminderParams,
36298
- account: accountParam
36591
+ account: accountParam,
36592
+ confirmToken: confirmTokenParam
36299
36593
  })
36300
- }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account }) => {
36594
+ }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account, confirmToken }, ctx) => {
36301
36595
  const args = ["calendar", "update", pos(calendarId), pos(eventId)];
36302
36596
  if (summary !== void 0) args.push(`--summary=${summary}`);
36303
36597
  if (from !== void 0) args.push(`--from=${from}`);
@@ -36312,6 +36606,45 @@ function registerCalendarTools(server) {
36312
36606
  if (removeZoom) args.push("--remove-zoom");
36313
36607
  if (removeMeet) args.push("--remove-meet");
36314
36608
  pushReminderFlags(args, { reminders, noReminders });
36609
+ const changes = Object.fromEntries(Object.entries({
36610
+ summary,
36611
+ from,
36612
+ to,
36613
+ description,
36614
+ location,
36615
+ attendees,
36616
+ addAttendees,
36617
+ attachments,
36618
+ withZoom,
36619
+ regenerateZoom,
36620
+ removeZoom,
36621
+ removeMeet
36622
+ }).filter(([, v]) => v !== void 0 && v !== false));
36623
+ if (Object.keys(changes).length > 0) {
36624
+ const read = await readEvent(calendarId, eventId, account);
36625
+ if (read.error) return read.error;
36626
+ const { etag, ...current } = read.event;
36627
+ const added = [...parseAttendees(attendees), ...parseAttendees(addAttendees)];
36628
+ if (current.guests.length > 0 || added.length > 0) {
36629
+ const view = { calendarId, eventId, current, changes };
36630
+ const confirmation = await requireDispatchConfirmation(ctx, {
36631
+ action: "calendar.update",
36632
+ message: "Review and confirm this change to an event other people are on:",
36633
+ confirmationLabel: CALENDAR_CONFIRM_LABEL,
36634
+ details: view,
36635
+ unsupportedNote: "Ask the user to make this change in Google Calendar.",
36636
+ fallback: {
36637
+ tool: "gog_calendar_update",
36638
+ account,
36639
+ confirmToken,
36640
+ // The etag rotates on any edit, so an event changed elsewhere between
36641
+ // the two phases is DRAFT_CHANGED rather than edited over.
36642
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view })
36643
+ }
36644
+ });
36645
+ if (confirmation) return confirmation;
36646
+ }
36647
+ }
36315
36648
  return runOrDiagnose(args, { account });
36316
36649
  });
36317
36650
  server.registerTool("gog_calendar_delete", {
@@ -36326,21 +36659,40 @@ function registerCalendarTools(server) {
36326
36659
  return runOrDiagnose(["calendar", "delete", pos(calendarId), pos(eventId), "--force"], { account });
36327
36660
  });
36328
36661
  server.registerTool("gog_calendar_respond", {
36329
- description: "Respond to a calendar event invitation.",
36662
+ 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,
36330
36663
  annotations: { destructiveHint: true },
36331
36664
  inputSchema: external_exports.object({
36332
36665
  calendarId: external_exports.string().describe("Calendar ID"),
36333
36666
  eventId: external_exports.string().describe("Event ID"),
36334
36667
  status: external_exports.enum(["accepted", "declined", "tentative"]).describe("Response status"),
36335
36668
  comment: external_exports.string().optional().describe("Optional comment to include with response"),
36336
- account: accountParam
36669
+ account: accountParam,
36670
+ confirmToken: confirmTokenParam
36337
36671
  })
36338
- }, async ({ calendarId, eventId, status, comment, account }) => {
36672
+ }, async ({ calendarId, eventId, status, comment, account, confirmToken }, ctx) => {
36339
36673
  const args = ["calendar", "respond", pos(calendarId), pos(eventId), `--status=${status}`];
36340
36674
  if (comment) args.push(`--comment=${comment}`);
36675
+ const read = await readEvent(calendarId, eventId, account);
36676
+ if (read.error) return read.error;
36677
+ const { etag, ...event } = read.event;
36678
+ const view = { calendarId, eventId, event, response: status, ...comment ? { comment } : {} };
36679
+ const confirmation = await requireDispatchConfirmation(ctx, {
36680
+ action: "calendar.respond",
36681
+ message: "Review and confirm this response, which the organizer will see:",
36682
+ confirmationLabel: "Confirm that this response should be recorded now.",
36683
+ details: view,
36684
+ unsupportedNote: "Ask the user to respond from Google Calendar.",
36685
+ fallback: {
36686
+ tool: "gog_calendar_respond",
36687
+ account,
36688
+ confirmToken,
36689
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view })
36690
+ }
36691
+ });
36692
+ if (confirmation) return confirmation;
36341
36693
  return runOrDiagnose(args, { account });
36342
36694
  });
36343
- registerRunTool(server, { service: "calendar", examples: '"calendars", "freebusy"' });
36695
+ registerRunTool(server, { service: "calendar", examples: '"calendars", "freebusy"', vet: vetCalendarRun });
36344
36696
  }
36345
36697
 
36346
36698
  // src/attachments.ts
@@ -36424,6 +36776,23 @@ function inlineAttachmentArgs(flag, attachments, siblingArgs = []) {
36424
36776
  }
36425
36777
 
36426
36778
  // src/tools/chat.ts
36779
+ var CHAT_SEND_WORDS = /* @__PURE__ */ new Set(["send", "create", "post"]);
36780
+ function vetChatRun(subcommand, args) {
36781
+ const sub = subcommand.toLowerCase();
36782
+ if (sub !== "messages" && sub !== "dm") return void 0;
36783
+ const word = hasCommandWord(args, CHAT_SEND_WORDS);
36784
+ if (!word) return void 0;
36785
+ return gatedElsewhere(
36786
+ `gog chat ${sub} ${word.toLowerCase()}`,
36787
+ "gog_chat_run",
36788
+ "posts a message",
36789
+ sub === "dm" ? "gog_chat_dm_send" : "gog_chat_messages_send"
36790
+ );
36791
+ }
36792
+ var CHAT_CONFIRM = {
36793
+ message: "Review and confirm this Google Chat message:",
36794
+ confirmationLabel: "Confirm that this Chat message should be posted now."
36795
+ };
36427
36796
  function registerChatTools(server) {
36428
36797
  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.';
36429
36798
  const spaceParam = external_exports.string().describe(
@@ -36529,7 +36898,7 @@ function registerChatTools(server) {
36529
36898
  return runOrDiagnose(args, { account });
36530
36899
  });
36531
36900
  server.registerTool("gog_chat_messages_send", {
36532
- 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,
36901
+ 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,
36533
36902
  annotations: { destructiveHint: true },
36534
36903
  inputSchema: external_exports.object({
36535
36904
  space: spaceParam,
@@ -36539,9 +36908,10 @@ function registerChatTools(server) {
36539
36908
  "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)."
36540
36909
  ),
36541
36910
  attachInline: attachInlineParam,
36542
- account: accountParam
36911
+ account: accountParam,
36912
+ confirmToken: confirmTokenParam
36543
36913
  })
36544
- }, async ({ space, text, thread, attach, attachInline, account }) => {
36914
+ }, async ({ space, text, thread, attach, attachInline, account, confirmToken }, ctx) => {
36545
36915
  confinePaths(attach, "attach");
36546
36916
  if (text === void 0 && !attach?.length && !attachInline?.length) {
36547
36917
  throw new Error("A Chat message needs text, an attachment, or both.");
@@ -36551,20 +36921,56 @@ function registerChatTools(server) {
36551
36921
  if (thread) args.push(`--thread=${thread}`);
36552
36922
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
36553
36923
  args.push(...inlineAttachmentArgs("attach", attachInline, args));
36924
+ const confirmation = await requireDispatchConfirmation(ctx, {
36925
+ ...CHAT_CONFIRM,
36926
+ action: "chat.message-send",
36927
+ details: { space, thread, textPreview: bodyPreview(text), attachments: attachmentNames(attach, attachInline) },
36928
+ fallback: {
36929
+ tool: "gog_chat_messages_send",
36930
+ account,
36931
+ confirmToken,
36932
+ subject: () => {
36933
+ const attachments = attachmentDetails(attach, attachInline);
36934
+ const from = senderPreview(account);
36935
+ return {
36936
+ target: space,
36937
+ payload: { from, space, thread, text, attachments },
36938
+ preview: { from, space, thread, text, attachments: attachmentPreview(attachments) }
36939
+ };
36940
+ }
36941
+ }
36942
+ });
36943
+ if (confirmation) return confirmation;
36554
36944
  return runOrDiagnose(args, { account });
36555
36945
  });
36556
36946
  server.registerTool("gog_chat_dm_send", {
36557
- 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,
36947
+ 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,
36558
36948
  annotations: { destructiveHint: true },
36559
36949
  inputSchema: external_exports.object({
36560
36950
  email: external_exports.string().describe("Recipient email address"),
36561
36951
  text: external_exports.string().describe("Message text"),
36562
36952
  thread: threadParam,
36563
- account: accountParam
36953
+ account: accountParam,
36954
+ confirmToken: confirmTokenParam
36564
36955
  })
36565
- }, async ({ email: email3, text, thread, account }) => {
36956
+ }, async ({ email: email3, text, thread, account, confirmToken }, ctx) => {
36566
36957
  const args = ["chat", "dm", "send", pos(email3), `--text=${text}`];
36567
36958
  if (thread) args.push(`--thread=${thread}`);
36959
+ const confirmation = await requireDispatchConfirmation(ctx, {
36960
+ ...CHAT_CONFIRM,
36961
+ action: "chat.dm-send",
36962
+ details: { to: email3, thread, textPreview: bodyPreview(text) },
36963
+ fallback: {
36964
+ tool: "gog_chat_dm_send",
36965
+ account,
36966
+ confirmToken,
36967
+ subject: () => {
36968
+ const from = senderPreview(account);
36969
+ return { target: email3, payload: { from, to: email3, thread, text }, preview: { from, to: email3, thread, text } };
36970
+ }
36971
+ }
36972
+ });
36973
+ if (confirmation) return confirmation;
36568
36974
  return runOrDiagnose(args, { account });
36569
36975
  });
36570
36976
  server.registerTool("gog_chat_dm_space", {
@@ -36619,11 +37025,45 @@ function registerChatTools(server) {
36619
37025
  registerRunTool(server, {
36620
37026
  service: "chat",
36621
37027
  examples: '"spaces", "messages", "dm"',
37028
+ vet: vetChatRun,
36622
37029
  note: "Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes."
36623
37030
  });
36624
37031
  }
36625
37032
 
36626
37033
  // src/tools/classroom.ts
37034
+ var CLASSROOM_CREATE_WORDS = /* @__PURE__ */ new Set(["create", "add", "new"]);
37035
+ var CLASSROOM_GATED = {
37036
+ announcements: { does: "posts to a class", tool: "gog_classroom_announcements_create" },
37037
+ announcement: { does: "posts to a class", tool: "gog_classroom_announcements_create" },
37038
+ ann: { does: "posts to a class", tool: "gog_classroom_announcements_create" },
37039
+ invitations: { does: "invites someone to a class", tool: "gog_classroom_invitations_create" },
37040
+ invitation: { does: "invites someone to a class", tool: "gog_classroom_invitations_create" },
37041
+ invites: { does: "invites someone to a class", tool: "gog_classroom_invitations_create" }
37042
+ };
37043
+ function vetClassroomRun(subcommand, args) {
37044
+ const sub = subcommand.toLowerCase();
37045
+ const gated = Object.hasOwn(CLASSROOM_GATED, sub) ? CLASSROOM_GATED[sub] : void 0;
37046
+ if (!gated) return void 0;
37047
+ const word = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
37048
+ return word ? gatedElsewhere(`gog classroom ${sub} ${word.toLowerCase()}`, "gog_classroom_run", gated.does, gated.tool) : void 0;
37049
+ }
37050
+ async function readCourse(courseId, account, runner = runOrDiagnose) {
37051
+ const got = await runner(["classroom", "courses", "get", pos(courseId)], { account });
37052
+ if (got.isError) return { error: got };
37053
+ let course;
37054
+ try {
37055
+ course = JSON.parse(resultText(got))?.course;
37056
+ } catch {
37057
+ course = void 0;
37058
+ }
37059
+ return {
37060
+ course: {
37061
+ id: courseId,
37062
+ ...typeof course?.name === "string" ? { name: course.name } : {},
37063
+ ...typeof course?.section === "string" ? { section: course.section } : {}
37064
+ }
37065
+ };
37066
+ }
36627
37067
  function registerClassroomTools(server) {
36628
37068
  server.registerTool("gog_classroom_courses_list", {
36629
37069
  description: "List Google Classroom courses.",
@@ -36906,19 +37346,42 @@ function registerClassroomTools(server) {
36906
37346
  return runOrDiagnose(["classroom", "announcements", "get", pos(courseId), pos(announcementId)], { account });
36907
37347
  });
36908
37348
  server.registerTool("gog_classroom_announcements_create", {
36909
- description: "Create an announcement in a Google Classroom course.",
37349
+ 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,
36910
37350
  annotations: { destructiveHint: true },
36911
37351
  inputSchema: external_exports.object({
36912
37352
  courseId: external_exports.string().describe("Course ID"),
36913
37353
  text: external_exports.string().describe("Announcement text"),
36914
- state: external_exports.enum(["PUBLISHED", "DRAFT"]).optional().describe("State"),
37354
+ state: external_exports.enum(["PUBLISHED", "DRAFT"]).optional().describe("State (DRAFT is visible only to teachers and needs no confirmation)"),
36915
37355
  scheduled: external_exports.string().optional().describe("Scheduled publish time"),
36916
- account: accountParam
37356
+ account: accountParam,
37357
+ confirmToken: confirmTokenParam
36917
37358
  })
36918
- }, async ({ courseId, text, state, scheduled, account }) => {
37359
+ }, async ({ courseId, text, state, scheduled, account, confirmToken }, ctx) => {
36919
37360
  const args = ["classroom", "announcements", "create", pos(courseId), `--text=${text}`];
36920
37361
  if (state) args.push(`--state=${state}`);
36921
37362
  if (scheduled) args.push(`--scheduled=${scheduled}`);
37363
+ if (state !== "DRAFT") {
37364
+ const read = await readCourse(courseId, account);
37365
+ if (read.error) return read.error;
37366
+ const publishes = scheduled ? `at ${scheduled}` : "immediately";
37367
+ const confirmation = await requireDispatchConfirmation(ctx, {
37368
+ action: "classroom.announcement-create",
37369
+ message: "Review and confirm this Classroom announcement:",
37370
+ confirmationLabel: "Confirm that this announcement should be posted to the class.",
37371
+ details: { course: read.course, publishes, textPreview: bodyPreview(text) },
37372
+ unsupportedNote: "Create it with state DRAFT instead; the user can review and post it from Classroom.",
37373
+ fallback: {
37374
+ tool: "gog_classroom_announcements_create",
37375
+ account,
37376
+ confirmToken,
37377
+ subject: () => {
37378
+ const view = { course: read.course, publishes, text };
37379
+ return { target: courseId, payload: view, preview: view };
37380
+ }
37381
+ }
37382
+ });
37383
+ if (confirmation) return confirmation;
37384
+ }
36922
37385
  return runOrDiagnose(args, { account });
36923
37386
  });
36924
37387
  server.registerTool("gog_classroom_topics_list", {
@@ -37008,6 +37471,7 @@ function registerClassroomTools(server) {
37008
37471
  registerRunTool(server, {
37009
37472
  service: "classroom",
37010
37473
  examples: '"guardians", "materials", "guardian-invitations"',
37474
+ vet: vetClassroomRun,
37011
37475
  note: "Covers anything not wrapped by the dedicated tools (guardians, guardian-invitations, materials, coursework assignees, announcement assignees, etc.)."
37012
37476
  });
37013
37477
  }
@@ -37168,7 +37632,18 @@ function registerDocsTools(server) {
37168
37632
  }
37169
37633
 
37170
37634
  // src/tools/drive.ts
37635
+ function vetDriveRun(subcommand, _args) {
37636
+ return subcommand.toLowerCase() === "share" ? gatedElsewhere("gog drive share", "gog_drive_run", "grants access to a file", "gog_drive_share") : void 0;
37637
+ }
37171
37638
  var GOOGLE_DOC_MIME = "application/vnd.google-apps.document";
37639
+ function shareTargetMeta(raw) {
37640
+ try {
37641
+ const { name, mimeType } = fileMeta(raw);
37642
+ return { name, mimeType };
37643
+ } catch {
37644
+ return {};
37645
+ }
37646
+ }
37172
37647
  function fileMeta(raw) {
37173
37648
  const parsed = JSON.parse(raw);
37174
37649
  const f = parsed.file ?? parsed;
@@ -37291,7 +37766,7 @@ function registerDriveTools(server) {
37291
37766
  return runOrDiagnose(args, { account });
37292
37767
  });
37293
37768
  server.registerTool("gog_drive_share", {
37294
- description: "Share a Google Drive file or folder.",
37769
+ 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,
37295
37770
  annotations: { destructiveHint: true },
37296
37771
  inputSchema: external_exports.object({
37297
37772
  fileId: external_exports.string().describe("File or folder ID"),
@@ -37299,9 +37774,35 @@ function registerDriveTools(server) {
37299
37774
  email: external_exports.string().optional().describe("User email (required when to=user)"),
37300
37775
  domain: external_exports.string().optional().describe("Domain (required when to=domain)"),
37301
37776
  role: external_exports.enum(["reader", "writer"]).optional().describe("Permission role (default: reader)"),
37302
- account: accountParam
37777
+ account: accountParam,
37778
+ confirmToken: confirmTokenParam
37303
37779
  })
37304
- }, async ({ fileId, to, email: email3, domain: domain2, role, account }) => {
37780
+ }, async ({ fileId, to, email: email3, domain: domain2, role, account, confirmToken }, ctx) => {
37781
+ const got = await runOrDiagnose(["drive", "get", pos(fileId)], { account });
37782
+ if (got.isError) return got;
37783
+ const file2 = { id: fileId, ...shareTargetMeta(resultText(got)) };
37784
+ const grant = {
37785
+ file: file2,
37786
+ to,
37787
+ ...email3 ? { email: email3 } : {},
37788
+ ...domain2 ? { domain: domain2 } : {},
37789
+ role: role ?? "reader",
37790
+ publicLink: to === "anyone"
37791
+ };
37792
+ const confirmation = await requireDispatchConfirmation(ctx, {
37793
+ action: "drive.share",
37794
+ message: to === "anyone" ? "Review and confirm making this file available to ANYONE with the link:" : "Review and confirm this Drive share:",
37795
+ confirmationLabel: "Confirm that this access should be granted now.",
37796
+ details: grant,
37797
+ unsupportedNote: "Ask the user to share it themselves from Google Drive.",
37798
+ fallback: {
37799
+ tool: "gog_drive_share",
37800
+ account,
37801
+ confirmToken,
37802
+ subject: () => ({ target: fileId, payload: grant, preview: grant })
37803
+ }
37804
+ });
37805
+ if (confirmation) return confirmation;
37305
37806
  const args = ["drive", "share", pos(fileId), `--to=${to}`];
37306
37807
  if (email3) args.push(`--email=${email3}`);
37307
37808
  if (domain2) args.push(`--domain=${domain2}`);
@@ -37414,7 +37915,7 @@ function registerDriveTools(server) {
37414
37915
  return diagnose(err);
37415
37916
  }
37416
37917
  });
37417
- registerRunTool(server, { service: "drive", examples: '"copy", "download", "permissions"' });
37918
+ registerRunTool(server, { service: "drive", examples: '"copy", "download", "permissions"', vet: vetDriveRun });
37418
37919
  }
37419
37920
 
37420
37921
  // src/gmail-results.ts
@@ -37540,24 +38041,16 @@ var STAGING_TWIN = {
37540
38041
  var UNSUPPORTED_NOTE2 = {
37541
38042
  "gmail.drafts-send": "The draft is still saved: ask the user to review it and send it from Gmail."
37542
38043
  };
37543
- var BODY_PREVIEW_MAX = 2048;
37544
- function bodyPreview(text) {
37545
- if (!text) return void 0;
37546
- if (text.length <= BODY_PREVIEW_MAX) return text;
37547
- return `${text.slice(0, BODY_PREVIEW_MAX)}\u2026 [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
37548
- }
37549
- function attachmentNames(paths, inline) {
37550
- return [...paths ?? [], ...(inline ?? []).map((a) => a.filename)];
37551
- }
37552
- function requireGmailDispatchConfirmation(ctx, op, details) {
38044
+ async function requireGmailDispatchConfirmation(ctx, op, details, fallback) {
37553
38045
  const twin = STAGING_TWIN[op];
37554
38046
  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];
37555
- return requireConfirmation(ctx, {
38047
+ return requireDispatchConfirmation(ctx, {
37556
38048
  action: op,
37557
38049
  message: op === "gmail.filter-forward" ? "Review and confirm this mail-forwarding filter:" : "Review and confirm this email dispatch:",
37558
38050
  details,
37559
38051
  confirmationLabel: op === "gmail.filter-forward" ? "Confirm that matching mail should be forwarded automatically from now on." : "Confirm that this email should be sent now.",
37560
- ...note ? { unsupportedNote: note } : {}
38052
+ unsupportedNote: note,
38053
+ ...fallback ? { fallback: { instruction: CONFIRM_SEND_INSTRUCTION, ...fallback } } : {}
37561
38054
  });
37562
38055
  }
37563
38056
  var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
@@ -37611,10 +38104,6 @@ function logGmailDispatch(tool, recipients, account) {
37611
38104
  process.stderr.write(`${JSON.stringify(event)}
37612
38105
  `);
37613
38106
  }
37614
- function resultText(result) {
37615
- const first = result.content[0];
37616
- return first && first.type === "text" && typeof first.text === "string" ? first.text : "{}";
37617
- }
37618
38107
 
37619
38108
  // src/tools/gmail.ts
37620
38109
  var replySchema = {
@@ -37662,7 +38151,7 @@ function appendReplyFlags(args, f) {
37662
38151
  if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
37663
38152
  args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
37664
38153
  }
37665
- var sendReplySchema = external_exports.object(replySchema);
38154
+ var sendReplySchema = external_exports.object({ ...replySchema, confirmToken: confirmTokenParam });
37666
38155
  function parseMetadataHeaders(raw) {
37667
38156
  let parsed;
37668
38157
  try {
@@ -37687,12 +38176,69 @@ function computeReplyRecipients(kind, headers, flags) {
37687
38176
  }
37688
38177
  return emails;
37689
38178
  }
37690
- async function sendReply(kind, toolName, messageId, account, flags, ctx) {
38179
+ function metadataThreadId(raw) {
38180
+ try {
38181
+ const threadId = JSON.parse(raw)?.message?.threadId;
38182
+ return typeof threadId === "string" ? threadId : void 0;
38183
+ } catch {
38184
+ return void 0;
38185
+ }
38186
+ }
38187
+ function replyTokenSubject(kind, messageId, account, headers, threadId, flags) {
38188
+ const removed = new Set(extractEmails(...flags.remove ?? []));
38189
+ const keep = (emails) => emails.filter((e) => !removed.has(e));
38190
+ const to = keep(extractEmails(headers.from, ...kind === "reply-all" ? [headers.to] : [], ...flags.to ?? []));
38191
+ const cc = keep(extractEmails(...kind === "reply-all" ? [headers.cc] : [], ...flags.cc ?? []));
38192
+ const bcc = keep(extractEmails(...flags.bcc ?? []));
38193
+ const attachments = attachmentDetails(flags.attach, flags.attachInline);
38194
+ const subject = flags.subject || (headers.subject ? `Re: ${headers.subject}` : void 0);
38195
+ const from = flags.from ?? (flags.autoFromAddressedAlias ? "the send-as alias the original was addressed to" : senderPreview(account));
38196
+ const localFiles = attachmentDetails([flags.bodyHtmlFile, flags.signatureFile].filter((p) => Boolean(p)), void 0);
38197
+ return {
38198
+ target: messageId,
38199
+ payload: {
38200
+ kind,
38201
+ to,
38202
+ cc,
38203
+ bcc,
38204
+ subject,
38205
+ from,
38206
+ body: flags.body,
38207
+ bodyHtml: flags.bodyHtml,
38208
+ localFiles,
38209
+ signature: Boolean(flags.signature),
38210
+ signatureFrom: flags.signatureFrom,
38211
+ attachments,
38212
+ quote: !flags.noQuote,
38213
+ threadId,
38214
+ inReplyTo: headers.message_id,
38215
+ references: headers.references
38216
+ },
38217
+ preview: {
38218
+ from,
38219
+ to,
38220
+ cc,
38221
+ bcc,
38222
+ subject,
38223
+ body: flags.body,
38224
+ ...flags.bodyHtml ? { bodyHtml: flags.bodyHtml } : {},
38225
+ ...flags.bodyHtmlFile ? { bodyHtmlFile: flags.bodyHtmlFile } : {},
38226
+ ...flags.signature || flags.signatureFrom || flags.signatureFile ? { signature: flags.signatureFile ?? flags.signatureFrom ?? "the Gmail signature of the sending address" } : {},
38227
+ quotesOriginal: !flags.noQuote,
38228
+ attachments: attachmentPreview(attachments),
38229
+ replyingToMessageId: messageId,
38230
+ threadId,
38231
+ inReplyTo: headers.message_id
38232
+ }
38233
+ };
38234
+ }
38235
+ async function sendReply(kind, toolName, messageId, account, flags, ctx, confirmToken) {
37691
38236
  const metaResult = await runOrDiagnose(["gmail", "get", pos(messageId), "--format=metadata"], { account });
37692
38237
  if (metaResult.isError) return metaResult;
37693
- const headers = parseMetadataHeaders(resultText(metaResult));
38238
+ const metaText = resultText(metaResult);
38239
+ const headers = parseMetadataHeaders(metaText);
37694
38240
  const recipients = computeReplyRecipients(kind, headers, flags);
37695
- const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
38241
+ const confirmation = await requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
37696
38242
  messageId,
37697
38243
  recipients,
37698
38244
  recipientCount: recipients.length,
@@ -37703,6 +38249,13 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
37703
38249
  bodyHtmlFile: flags.bodyHtmlFile,
37704
38250
  attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
37705
38251
  attachments: attachmentNames(flags.attach, flags.attachInline)
38252
+ }, {
38253
+ tool: toolName,
38254
+ account,
38255
+ confirmToken,
38256
+ // The metadata read above IS the phase-2 re-read: it runs on every call,
38257
+ // so the original's Message-ID/References are fresh here.
38258
+ subject: () => replyTokenSubject(kind, messageId, account, headers, metadataThreadId(metaText), flags)
37706
38259
  });
37707
38260
  if (confirmation) return confirmation;
37708
38261
  const args = ["gmail", kind, pos(messageId)];
@@ -37720,7 +38273,7 @@ function vetGmailRun(subcommand, args) {
37720
38273
  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.`;
37721
38274
  }
37722
38275
  if (subcommand === "settings") {
37723
- const blocked = args.find((a) => GMAIL_RUN_BLOCKED_SETTINGS.has(a.toLowerCase()));
38276
+ const blocked = hasCommandWord(args, GMAIL_RUN_BLOCKED_SETTINGS);
37724
38277
  if (blocked) {
37725
38278
  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.`;
37726
38279
  }
@@ -37779,7 +38332,7 @@ function registerGmailTools(server) {
37779
38332
  return runOrDiagnose(args, { account });
37780
38333
  });
37781
38334
  server.registerTool("gog_gmail_send", {
37782
- 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.',
38335
+ 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,
37783
38336
  annotations: { destructiveHint: true },
37784
38337
  inputSchema: external_exports.object({
37785
38338
  to: external_exports.string().describe("Recipient(s), comma-separated"),
@@ -37792,9 +38345,10 @@ function registerGmailTools(server) {
37792
38345
  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."),
37793
38346
  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).`),
37794
38347
  attachInline: attachInlineParam,
37795
- account: accountParam
38348
+ account: accountParam,
38349
+ confirmToken: confirmTokenParam
37796
38350
  })
37797
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
38351
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account, confirmToken }, ctx) => {
37798
38352
  confinePaths(attach, "attach");
37799
38353
  const args = ["gmail", "send", `--to=${to}`, `--subject=${subject}`, payloadArg("body", "body-file", body)];
37800
38354
  if (cc) args.push(`--cc=${cc}`);
@@ -37806,7 +38360,7 @@ function registerGmailTools(server) {
37806
38360
  const inline = inlineAttachmentArgs("attach", attachInline, args);
37807
38361
  args.push(...inline);
37808
38362
  const recipients = extractEmails(to, cc, bcc);
37809
- const confirmation = requireGmailDispatchConfirmation(ctx, "gmail.send", {
38363
+ const confirmation = await requireGmailDispatchConfirmation(ctx, "gmail.send", {
37810
38364
  to,
37811
38365
  cc,
37812
38366
  bcc,
@@ -37819,6 +38373,32 @@ function registerGmailTools(server) {
37819
38373
  quoting: Boolean(quote),
37820
38374
  attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
37821
38375
  attachments: attachmentNames(attach, attachInline)
38376
+ }, {
38377
+ tool: "gog_gmail_send",
38378
+ account,
38379
+ confirmToken,
38380
+ // Nothing is stored between the phases: the payload IS the arguments,
38381
+ // so phase 2 must repeat them and any difference is a changed send.
38382
+ subject: () => {
38383
+ const attachments = attachmentDetails(attach, attachInline);
38384
+ const from = senderPreview(account);
38385
+ return {
38386
+ target: replyToMessageId ?? threadId ?? "",
38387
+ payload: { from, to, cc, bcc, subject, body, attachments, threadId, inReplyTo: replyToMessageId, quote: Boolean(quote) },
38388
+ preview: {
38389
+ from,
38390
+ to,
38391
+ cc,
38392
+ bcc,
38393
+ subject,
38394
+ body,
38395
+ attachments: attachmentPreview(attachments),
38396
+ threadId,
38397
+ inReplyTo: replyToMessageId,
38398
+ quotesOriginal: Boolean(quote)
38399
+ }
38400
+ };
38401
+ }
37822
38402
  });
37823
38403
  if (confirmation) return confirmation;
37824
38404
  const result = await runOrDiagnose(args, { account });
@@ -37826,22 +38406,22 @@ function registerGmailTools(server) {
37826
38406
  return result;
37827
38407
  });
37828
38408
  server.registerTool("gog_gmail_reply", {
37829
- 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.',
38409
+ 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,
37830
38410
  annotations: { destructiveHint: true },
37831
38411
  inputSchema: sendReplySchema
37832
- }, async ({ messageId, account, ...flags }, ctx) => {
38412
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
37833
38413
  assertNotBoth("bodyHtml", "bodyHtmlFile", flags.bodyHtml, flags.bodyHtmlFile);
37834
38414
  confineReplyPaths(flags);
37835
- return sendReply("reply", "gog_gmail_reply", messageId, account, flags, ctx);
38415
+ return sendReply("reply", "gog_gmail_reply", messageId, account, flags, ctx, confirmToken);
37836
38416
  });
37837
38417
  server.registerTool("gog_gmail_reply_all", {
37838
- 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.',
38418
+ 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,
37839
38419
  annotations: { destructiveHint: true },
37840
38420
  inputSchema: sendReplySchema
37841
- }, async ({ messageId, account, ...flags }, ctx) => {
38421
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
37842
38422
  assertNotBoth("bodyHtml", "bodyHtmlFile", flags.bodyHtml, flags.bodyHtmlFile);
37843
38423
  confineReplyPaths(flags);
37844
- return sendReply("reply-all", "gog_gmail_reply_all", messageId, account, flags, ctx);
38424
+ return sendReply("reply-all", "gog_gmail_reply_all", messageId, account, flags, ctx, confirmToken);
37845
38425
  });
37846
38426
  registerRunTool(server, {
37847
38427
  service: "gmail",
@@ -38182,7 +38762,7 @@ function registerTasksTools(server) {
38182
38762
  }
38183
38763
 
38184
38764
  // src/server.ts
38185
- var VERSION = true ? "4.3.0" : "0.0.0";
38765
+ var VERSION = true ? "4.4.0" : "0.0.0";
38186
38766
  var BASE_TOOL_REGISTRARS = [
38187
38767
  registerApiTools,
38188
38768
  registerAppScriptTools,
@@ -38234,12 +38814,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
38234
38814
  const [clientId, clientSecret, refreshToken, account] = values;
38235
38815
  const { spawner } = options;
38236
38816
  try {
38237
- const { createHash: createHash2 } = await import("node:crypto");
38817
+ const { createHash: createHash4 } = await import("node:crypto");
38238
38818
  const { mkdir, readFile, writeFile, chmod } = await import("node:fs/promises");
38239
38819
  const home = options.home ?? (await import("node:os")).homedir();
38240
38820
  const dir = join5(home, ".gogcli-mcp");
38241
38821
  const marker = join5(dir, AUTH_BOOTSTRAP_MARKER);
38242
- const fingerprint = createHash2("sha256").update(values.join("\0")).digest("hex");
38822
+ const fingerprint = createHash4("sha256").update(values.join("\0")).digest("hex");
38243
38823
  const previous = await readFile(marker, "utf8").catch(() => void 0);
38244
38824
  if (previous === fingerprint && await accountListed(account, spawner)) return "present";
38245
38825
  await run(