gogcli-mcp-gmail 2.7.1 → 2.18.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.
@@ -1,6 +1,36 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, toText, type ToolResult } from '../../../gogcli-mcp/src/lib.js';
3
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
+ import { rawTextResult, textResult, errorResult } from '@chrischall/mcp-utils';
5
+ import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor } from '../../../gogcli-mcp/src/lib.js';
6
+ import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
7
+
8
+ // gog rejects an inline flag together with its --*-file twin — `gmail drafts
9
+ // create` errors with "use only one of --body-html or --body-html-file", and
10
+ // `gmail forward` does the same for --note (misreporting it as --body). Catch
11
+ // the conflict here so the caller gets a message naming the TOOL params it
12
+ // actually passed, instead of a gog error naming flags it never saw.
13
+ function assertNotBoth(
14
+ inlineParam: string,
15
+ fileParam: string,
16
+ inlineValue: string | undefined,
17
+ fileValue: string | undefined,
18
+ ): void {
19
+ if (inlineValue !== undefined && fileValue !== undefined) {
20
+ throw new Error(
21
+ `${inlineParam} and ${fileParam} are mutually exclusive — gog accepts only one of them. ` +
22
+ `Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), ` +
23
+ `or ${fileParam} with a path that already exists on the gog server.`,
24
+ );
25
+ }
26
+ }
27
+
28
+ // Pull the text out of a single-text-block tool result; undefined for any
29
+ // other shape (an error result is still a text block, so it parses below).
30
+ function resultText(result: CallToolResult): string | undefined {
31
+ const first = result.content[0];
32
+ return first?.type === 'text' ? first.text : undefined;
33
+ }
4
34
 
5
35
  type GmailHeader = { name?: string; value?: string };
6
36
  type GmailMessage = {
@@ -41,23 +71,248 @@ function summarizeMessage(m: GmailMessage): Record<string, unknown> {
41
71
  // message-limit flag, so this is done by post-processing its output. Any
42
72
  // non-JSON output (an error, an unexpected shape) is passed through untouched.
43
73
  function trimThread(
44
- result: ToolResult,
74
+ result: CallToolResult,
45
75
  latestN: number | undefined,
46
76
  snippetsOnly: boolean | undefined,
47
- ): ToolResult {
77
+ ): CallToolResult {
48
78
  try {
49
- const parsed = JSON.parse(result.content[0].text) as { thread?: { messages?: unknown[] } };
79
+ const parsed = JSON.parse(resultText(result) ?? '') as { thread?: { messages?: unknown[] } };
50
80
  const messages = parsed.thread?.messages;
51
81
  if (!Array.isArray(messages)) return result;
52
82
  let trimmed: unknown[] = messages;
53
83
  if (latestN !== undefined) trimmed = trimmed.slice(-latestN);
54
84
  if (snippetsOnly) trimmed = trimmed.map((m) => summarizeMessage(m as GmailMessage));
55
- return toText(JSON.stringify({ ...parsed, thread: { ...parsed.thread, messages: trimmed } }));
85
+ return rawTextResult(JSON.stringify({ ...parsed, thread: { ...parsed.thread, messages: trimmed } }));
56
86
  } catch {
57
87
  return result;
58
88
  }
59
89
  }
60
90
 
91
+ // `gog gmail attachment --inline --json` emits these fields (NO filename, NO MIME
92
+ // type): base64 content when the attachment is within gog's 3 MiB inline cap,
93
+ // otherwise just the on-disk path plus a `reason` explaining the size fallback.
94
+ type InlineAttachment = {
95
+ path?: string;
96
+ bytes?: number;
97
+ cached?: boolean;
98
+ contentBase64?: string;
99
+ reason?: string;
100
+ };
101
+
102
+ // One entry from `gog gmail get --json` `.attachments[]`: the message part
103
+ // metadata, which carries the TRUE filename and MIME type that the download
104
+ // endpoint itself does not report. `size` is the stable key we match on —
105
+ // Gmail's `attachmentId` is NOT stable across calls (see resolveBySize).
106
+ type AttachmentMeta = { filename?: string; mimeType?: string; attachmentId?: string; size?: number };
107
+
108
+ // MIME type by file extension — the download endpoint reports none, and the
109
+ // client needs it to render an inline image or label an embedded resource. Part
110
+ // metadata (authoritative) and a magic-byte sniff (below) backstop this.
111
+ const MIME_BY_EXT: Record<string, string> = {
112
+ pdf: 'application/pdf',
113
+ png: 'image/png',
114
+ jpg: 'image/jpeg',
115
+ jpeg: 'image/jpeg',
116
+ gif: 'image/gif',
117
+ webp: 'image/webp',
118
+ svg: 'image/svg+xml',
119
+ bmp: 'image/bmp',
120
+ tiff: 'image/tiff',
121
+ heic: 'image/heic',
122
+ txt: 'text/plain',
123
+ csv: 'text/csv',
124
+ md: 'text/markdown',
125
+ json: 'application/json',
126
+ xml: 'application/xml',
127
+ html: 'text/html',
128
+ htm: 'text/html',
129
+ ics: 'text/calendar',
130
+ zip: 'application/zip',
131
+ doc: 'application/msword',
132
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
133
+ xls: 'application/vnd.ms-excel',
134
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
135
+ ppt: 'application/vnd.ms-powerpoint',
136
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
137
+ };
138
+
139
+ // Reverse map for naming a part that carried no filename: MIME type → a canonical
140
+ // extension. Deliberate: a known type must never be saved as `*.bin`. Later
141
+ // entries win, so image/jpeg resolves to `jpeg` and text/html to `htm` — both fine.
142
+ const EXT_BY_MIME: Record<string, string> = Object.fromEntries(
143
+ Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext]),
144
+ );
145
+
146
+ // Lowercased extension of a filename, or '' if it has none.
147
+ function extOf(name: string): string {
148
+ const dot = name.lastIndexOf('.');
149
+ return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
150
+ }
151
+
152
+ // Make a caller- or part-supplied name safe as a SINGLE path segment: no
153
+ // directory separators or traversal (it is interpolated into an --out path gog
154
+ // creates server-side), no control chars, bounded length.
155
+ function sanitizeFilename(name: string): string {
156
+ const base = name
157
+ .replace(/[/\\]+/g, '_')
158
+ // eslint-disable-next-line no-control-regex
159
+ .replace(/[\x00-\x1f]/g, '')
160
+ .replace(/^\.+/, '')
161
+ .trim()
162
+ .slice(0, 200);
163
+ return base || 'attachment';
164
+ }
165
+
166
+ // Leading magic bytes → MIME type, for the common binary attachments.
167
+ const MAGIC_SIGNATURES: ReadonlyArray<readonly [string, string]> = [
168
+ ['%PDF', 'application/pdf'],
169
+ ['\x89PNG', 'image/png'],
170
+ ['\xFF\xD8\xFF', 'image/jpeg'],
171
+ ['GIF8', 'image/gif'],
172
+ ];
173
+
174
+ // Sniff a MIME type from the leading bytes of standard base64; returns undefined
175
+ // for anything unrecognised. gog emits standard base64, and any 4-aligned prefix
176
+ // of a valid base64 string is itself valid, so atob never throws here.
177
+ function sniffMime(base64: string): string | undefined {
178
+ const head = atob(base64.slice(0, 16)); // 4-aligned slice; decodes to ~12 bytes
179
+ for (const [signature, mimeType] of MAGIC_SIGNATURES) {
180
+ if (head.startsWith(signature)) return mimeType;
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ // Resolve the real filename + MIME type from the message part metadata
186
+ // (`gog gmail get` `.attachments[]`), which the download endpoint doesn't return.
187
+ // A lightweight metadata read — no body bytes.
188
+ //
189
+ // The match is by SIZE, not attachmentId: Gmail's attachmentId is not stable
190
+ // across API calls (a fresh `get` re-issues a different id for the same part, as
191
+ // verified live), so the caller's id can't be matched against a fresh listing.
192
+ // The downloaded byte count is stable and, in practice, unique per message — so
193
+ // it identifies the part. An ambiguous (repeated) size yields undefined, and the
194
+ // caller falls back to a magic-byte sniff + a MIME-derived name.
195
+ async function resolveBySize(
196
+ messageId: string,
197
+ sizeBytes: number | undefined,
198
+ account: string | undefined,
199
+ ): Promise<AttachmentMeta | undefined> {
200
+ if (sizeBytes === undefined) return undefined;
201
+ try {
202
+ const parsed = JSON.parse(await run(['gmail', 'get', messageId], { account })) as {
203
+ attachments?: AttachmentMeta[];
204
+ };
205
+ const matches = (parsed.attachments ?? []).filter((a) => a.size === sizeBytes);
206
+ return matches.length === 1 ? matches[0] : undefined;
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
211
+
212
+ // A writable, ephemeral server-side output path. gog MkdirAll's the tree, and
213
+ // /tmp is writable on both the local host and the Fly backend AND is cleared when
214
+ // the machine stops — unlike gog's default (the gogcli config dir), which on the
215
+ // Fly volume would accumulate downloaded attachments indefinitely.
216
+ function defaultOutPath(messageId: string, filename: string): string {
217
+ return `/tmp/gog-attachments/${messageId}/${filename}`;
218
+ }
219
+
220
+ // Strip the command echo and any message/attachment ids from a gog failure before
221
+ // it reaches the caller. On the Fly backend gog runs under execFile, whose error
222
+ // message is `Command failed: gog gmail attachment <msg> <token> ...\n<stderr>` —
223
+ // leaking the full command line (opaque attachment token included) and a raw shell
224
+ // error such as `mkdir /home/claude: permission denied`. Keep only the stderr
225
+ // tail, with the ids redacted.
226
+ function sanitizeAttachmentError(err: unknown, messageId: string, attachmentId: string): string {
227
+ let msg = err instanceof Error ? err.message : String(err);
228
+ msg = msg.replace(/^Command failed:.*(\n|$)/, ''); // drop the command echo line
229
+ msg = msg.split(attachmentId).join('<attachment>').split(messageId).join('<message>');
230
+ return msg.trim() || 'the download failed on the server';
231
+ }
232
+
233
+ // The attachment bytes as a native image block (so clients render them). A
234
+ // leading text block summarises the attachment for the model.
235
+ function inlineImageResult(summary: string, base64: string, mimeType: string): CallToolResult {
236
+ return { content: [{ type: 'text', text: summary }, { type: 'image', data: base64, mimeType }] };
237
+ }
238
+
239
+ // The attachment bytes as an MCP embedded-resource blob. Only some hosts render
240
+ // or accept non-image resources (claude.ai currently rejects application/pdf),
241
+ // so this is reserved for the explicit deliver="inline" escape hatch — deliver
242
+ // "auto" never routes bytes the host would silently drop through here.
243
+ function inlineResourceResult(
244
+ messageId: string,
245
+ filename: string,
246
+ summary: string,
247
+ base64: string,
248
+ mimeType: string,
249
+ ): CallToolResult {
250
+ return {
251
+ content: [
252
+ { type: 'text', text: summary },
253
+ {
254
+ type: 'resource',
255
+ resource: { uri: `gmail-attachment://${messageId}/${filename}`, mimeType, blob: base64 },
256
+ },
257
+ ],
258
+ };
259
+ }
260
+
261
+ // A file-path delivery: the bytes were written server-side and the caller reads
262
+ // them from `path`. Used on the local (stdio) transport, where the caller shares
263
+ // the filesystem — the remote connector uses Drive instead.
264
+ function fileResult(
265
+ path: string,
266
+ fileName: string,
267
+ mimeType: string,
268
+ bytes: number | undefined,
269
+ ): CallToolResult {
270
+ return textResult({
271
+ delivery: 'file',
272
+ path,
273
+ fileName,
274
+ mimeType,
275
+ bytes,
276
+ note: 'Saved on the server filesystem — read it from `path`.',
277
+ });
278
+ }
279
+
280
+ // Prepend an advisory note (e.g. why a caller-supplied `out` was ignored) to a
281
+ // result's content, or return it unchanged when there is nothing to say.
282
+ function withNote(result: CallToolResult, notes: string[]): CallToolResult {
283
+ if (notes.length === 0) return result;
284
+ return { ...result, content: [{ type: 'text', text: notes.join(' ') }, ...result.content] };
285
+ }
286
+
287
+ // Upload the file gog wrote (server-side, on the same box that ran the download)
288
+ // to Google Drive and return its metadata + shareable link. This is how a large
289
+ // or non-renderable attachment — one the connector can't hand back inline —
290
+ // reaches the caller.
291
+ async function deliverViaDrive(
292
+ path: string,
293
+ name: string,
294
+ driveFolder: string | undefined,
295
+ account: string | undefined,
296
+ ): Promise<CallToolResult> {
297
+ const args = ['drive', 'upload', path, '--json'];
298
+ if (driveFolder) args.push(`--parent=${driveFolder}`);
299
+ args.push(`--name=${name}`); // callers always resolve a filename first
300
+ // `gog drive upload --json` wraps the created file under a `file` key.
301
+ const parsed = JSON.parse(await run(args, { account })) as {
302
+ file?: { id?: string; name?: string; mimeType?: string; size?: number | string; webViewLink?: string };
303
+ };
304
+ const file = parsed.file ?? {};
305
+ return textResult({
306
+ deliveredVia: 'drive',
307
+ note: 'Attachment delivered via Google Drive; open or download it at webViewLink.',
308
+ id: file.id,
309
+ name: file.name,
310
+ mimeType: file.mimeType,
311
+ size: file.size,
312
+ webViewLink: file.webViewLink,
313
+ });
314
+ }
315
+
61
316
  export function registerExtraGmailTools(server: McpServer): void {
62
317
  server.registerTool('gog_gmail_raw', {
63
318
  description: 'Dump the raw Gmail API response as JSON (lossless; for scripting and LLM consumption).',
@@ -76,20 +331,126 @@ export function registerExtraGmailTools(server: McpServer): void {
76
331
  });
77
332
 
78
333
  server.registerTool('gog_gmail_attachment', {
79
- description: 'Download a single attachment from a Gmail message.',
80
- annotations: { readOnlyHint: true },
334
+ description:
335
+ 'Download a Gmail attachment and deliver its contents so you can actually read them. The real filename ' +
336
+ 'and MIME type are resolved from the message part metadata, so the saved file and response are named ' +
337
+ 'correctly (e.g. Guest_Copy.pdf), never a generic *.bin. deliver="auto" (default) is transport-aware: ' +
338
+ 'images always come back as a native image block; anything else is delivered by the channel that works ' +
339
+ 'on your transport — a readable server-side file PATH on local (stdio) clients that share the filesystem, ' +
340
+ 'or a Google Drive link on the remote connector (whose backend filesystem you can\'t read, and which ' +
341
+ 'rejects inline PDF/binary blobs). deliver="inline" forces the bytes inline as an image or embedded ' +
342
+ 'resource blob (use only if your client consumes resource blobs; errors if over gog\'s 3 MiB cap). ' +
343
+ 'deliver="drive" always uploads to Drive; deliver="off" writes the file server-side and returns ' +
344
+ '{path, fileName, mimeType, bytes}. Drive delivery creates a file in your Drive (blocked when GOG_READONLY is set).',
81
345
  inputSchema: {
82
346
  messageId: z.string().describe('Gmail message ID'),
83
347
  attachmentId: z.string().describe('Attachment ID (from the message payload)'),
84
- out: z.string().optional().describe('Output file path (default: gogcli config dir)'),
85
- name: z.string().optional().describe('Filename (used when --out is empty or points to a directory)'),
348
+ deliver: z
349
+ .enum(['auto', 'inline', 'drive', 'off'])
350
+ .optional()
351
+ .describe('How to return the contents: auto (image inline; else a local file path or a Drive link, per transport), inline (force bytes as image/resource blob), drive (always a Drive link), or off (server-side download only). Default: auto.'),
352
+ out: z.string().optional().describe('Server-side path where gog writes the file. NOTE: this resolves on the CONNECTOR/gog server\'s filesystem, not your machine — on the remote connector it is ignored (you can\'t read it; you get a Drive link instead). Locally it is honored. Omit it to use an ephemeral temp path.'),
353
+ name: z.string().optional().describe('Filename override. Defaults to the attachment\'s real filename from the message metadata; pass this to skip that lookup or force a name.'),
354
+ driveFolder: z.string().optional().describe('Destination Google Drive folder ID for the uploaded copy (drive/auto delivery on the remote connector, or oversized attachments).'),
86
355
  account: accountParam,
87
356
  },
88
- }, async ({ messageId, attachmentId, out, name, account }) => {
89
- const args = ['gmail', 'attachment', messageId, attachmentId];
90
- if (out) args.push(`--out=${out}`);
91
- if (name) args.push(`--name=${name}`);
92
- return runOrDiagnose(args, { account });
357
+ }, async ({ messageId, attachmentId, deliver = 'auto', out, name, driveFolder, account }) => {
358
+ // On the remote connector, `run` forwards to the Fly backend and this store
359
+ // is set; on local stdio it is unset. It is the one signal that tells apart
360
+ // "the caller shares my filesystem" (stdio → deliver a path) from "the caller
361
+ // can't read my disk and rejects binary blobs" (connector deliver a Drive
362
+ // link). It also decides whether a caller-supplied `out` is meaningful.
363
+ const remote = runExecutor.getStore() !== undefined;
364
+ try {
365
+ // 1. Start from the caller's `name` (the recommended path — the caller got
366
+ // the attachmentId from a listing that also carried the filename). The
367
+ // download endpoint reports neither name nor MIME; when `name` is absent
368
+ // we resolve them AFTER downloading, by matching the byte count against
369
+ // the part metadata (attachmentIds aren't stable enough to match on).
370
+ let filename = name ? sanitizeFilename(name) : undefined;
371
+ let mimeType: string | undefined = filename ? MIME_BY_EXT[extOf(filename)] : undefined;
372
+
373
+ // 2. Choose the server-side output path. A caller `out` only makes sense on
374
+ // the local transport; on the connector it resolves on the backend the
375
+ // caller can't read, so ignore it (with a note) and use a temp path. The
376
+ // on-disk basename is provisional when `name` is absent; the response
377
+ // still reports the resolved filename.
378
+ const notes: string[] = [];
379
+ let outPath = out;
380
+ if (out && remote) {
381
+ notes.push("`out` was ignored: it resolves on the connector's server filesystem, which you can't read.");
382
+ outPath = undefined;
383
+ }
384
+ if (!outPath) outPath = defaultOutPath(messageId, filename ?? 'attachment');
385
+
386
+ // 3. Download. --inline returns the bytes for the image/resource cases; skip
387
+ // it when we already know delivery is by path or Drive (don't ship base64
388
+ // only to discard it). When the MIME type is still unknown we must
389
+ // --inline to sniff it from the bytes.
390
+ let needInline = deliver === 'auto' || deliver === 'inline';
391
+ if (deliver === 'auto' && remote && mimeType && !mimeType.startsWith('image/')) {
392
+ needInline = false; // headed to Drive anyway
393
+ }
394
+ if (!mimeType && (deliver === 'auto' || deliver === 'inline')) {
395
+ needInline = true; // need the bytes to sniff the type
396
+ }
397
+ const args = ['gmail', 'attachment', messageId, attachmentId];
398
+ if (needInline) args.push('--inline');
399
+ args.push(`--out=${outPath}`, `--name=${filename ?? 'attachment'}`);
400
+ const info = JSON.parse(await run(args, { account })) as InlineAttachment;
401
+ const path = info.path ?? outPath;
402
+
403
+ // 4. Resolve the real filename/MIME when `name` wasn't supplied, by matching
404
+ // the downloaded byte count against the part metadata.
405
+ if (!filename) {
406
+ const meta = await resolveBySize(messageId, info.bytes, account);
407
+ if (meta?.filename) filename = sanitizeFilename(meta.filename);
408
+ if (!mimeType && meta?.mimeType) mimeType = meta.mimeType;
409
+ }
410
+ if (!mimeType && info.contentBase64) mimeType = sniffMime(info.contentBase64);
411
+ mimeType = mimeType ?? 'application/octet-stream';
412
+ // Still no filename → derive one from the MIME type; never leave it *.bin.
413
+ if (!filename) {
414
+ const ext = EXT_BY_MIME[mimeType];
415
+ filename = ext ? `attachment.${ext}` : 'attachment';
416
+ }
417
+ const isImage = mimeType.startsWith('image/');
418
+ const summary = `${filename} — ${info.bytes ?? '?'} bytes, ${mimeType}, returned inline.`;
419
+
420
+ // 5. Deliver by the requested channel. Every delivery is wrapped with
421
+ // `notes` so an ignored-`out` explanation is never silently dropped,
422
+ // whatever the mode or type.
423
+ if (deliver === 'off') {
424
+ return withNote(textResult({ delivery: 'file', path, cached: info.cached, bytes: info.bytes, fileName: filename, mimeType }), notes);
425
+ }
426
+ if (deliver === 'drive') {
427
+ return withNote(await deliverViaDrive(path, filename, driveFolder, account), notes);
428
+ }
429
+ if (deliver === 'inline') {
430
+ if (info.contentBase64) {
431
+ return withNote(isImage
432
+ ? inlineImageResult(summary, info.contentBase64, mimeType)
433
+ : inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
434
+ }
435
+ return errorResult(
436
+ `Attachment is too large to return inline (${info.reason ?? "exceeds gog's 3 MiB inline limit"}). ` +
437
+ 'Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.',
438
+ );
439
+ }
440
+ // deliver === 'auto': images render everywhere; everything else goes by the
441
+ // channel that works on this transport.
442
+ if (isImage && info.contentBase64) {
443
+ return withNote(inlineImageResult(summary, info.contentBase64, mimeType), notes);
444
+ }
445
+ if (remote) {
446
+ return withNote(await deliverViaDrive(path, filename, driveFolder, account), notes);
447
+ }
448
+ return withNote(fileResult(path, filename, mimeType, info.bytes), notes);
449
+ } catch (err) {
450
+ // Never surface gog's raw error (it echoes the full command line + attachment
451
+ // token on the backend); redact the ids and let diagnose classify the rest.
452
+ return diagnose(new Error(sanitizeAttachmentError(err, messageId, attachmentId)));
453
+ }
93
454
  });
94
455
 
95
456
  server.registerTool('gog_gmail_url', {
@@ -122,11 +483,12 @@ export function registerExtraGmailTools(server: McpServer): void {
122
483
  return runOrDiagnose(args, { account });
123
484
  });
124
485
 
125
- const bulkActions: Array<{ tool: string; cmd: string; description: string }> = [
486
+ const bulkActions: Array<{ tool: string; cmd: string; description: string; supportsThread?: boolean }> = [
126
487
  {
127
488
  tool: 'gog_gmail_archive',
128
489
  cmd: 'archive',
129
- description: 'Archive messages (remove from inbox). Pass either messageIds or a Gmail search query.',
490
+ description: 'Archive messages (remove from inbox). Pass either messageIds or a Gmail search query. Set thread=true to treat the ids as THREAD ids and archive every message in each thread (the right mode for ids that came from thread search).',
491
+ supportsThread: true,
130
492
  },
131
493
  {
132
494
  tool: 'gog_gmail_mark_read',
@@ -145,21 +507,29 @@ export function registerExtraGmailTools(server: McpServer): void {
145
507
  },
146
508
  ];
147
509
 
148
- for (const { tool, cmd, description } of bulkActions) {
510
+ for (const { tool, cmd, description, supportsThread } of bulkActions) {
511
+ const inputSchema: Record<string, z.ZodTypeAny> = {
512
+ messageIds: z.array(z.string()).optional().describe('Specific message IDs to act on'),
513
+ query: z.string().optional().describe('Gmail search query (alternative to messageIds; acts on all matching)'),
514
+ max: z.number().optional().describe('Max messages when using --query (default: 100)'),
515
+ account: accountParam,
516
+ };
517
+ if (supportsThread) {
518
+ inputSchema.thread = z.boolean().optional().describe('Treat messageIds as THREAD ids and act on every message in each thread');
519
+ }
149
520
  server.registerTool(tool, {
150
521
  description,
151
522
  annotations: { destructiveHint: true },
152
- inputSchema: {
153
- messageIds: z.array(z.string()).optional().describe('Specific message IDs to act on'),
154
- query: z.string().optional().describe('Gmail search query (alternative to messageIds; acts on all matching)'),
155
- max: z.number().optional().describe('Max messages when using --query (default: 100)'),
156
- account: accountParam,
157
- },
158
- }, async ({ messageIds, query, max, account }) => {
523
+ inputSchema,
524
+ }, async (rawArgs) => {
525
+ const { messageIds, query, max, thread, account } = rawArgs as {
526
+ messageIds?: string[]; query?: string; max?: number; thread?: boolean; account?: string;
527
+ };
159
528
  const args = ['gmail', cmd];
160
529
  if (messageIds) args.push(...messageIds);
161
530
  if (query) args.push(`--query=${query}`);
162
531
  if (max !== undefined) args.push(`--max=${max}`);
532
+ if (supportsThread && thread) args.push('--thread');
163
533
  return runOrDiagnose(args, { account });
164
534
  });
165
535
  }
@@ -181,14 +551,17 @@ export function registerExtraGmailTools(server: McpServer): void {
181
551
  });
182
552
 
183
553
  server.registerTool('gog_gmail_batch_delete', {
184
- description: 'Permanently delete multiple messages (requires the broader Gmail scope). Use gog_gmail_trash for normal deletes.',
554
+ description: 'Permanently delete multiple messages (requires the broader Gmail scope; not reversible — messages bypass Trash). Requires force:true to delete non-interactively. Use gog_gmail_trash for normal deletes.',
185
555
  annotations: { destructiveHint: true },
186
556
  inputSchema: {
187
557
  messageIds: z.array(z.string()).min(1).describe('Message IDs to permanently delete'),
558
+ force: z.boolean().optional().describe('Required to delete in this non-interactive context — without it the delete is refused as a safety guard.'),
188
559
  account: accountParam,
189
560
  },
190
- }, async ({ messageIds, account }) => {
191
- return runOrDiagnose(['gmail', 'batch', 'delete', ...messageIds], { account });
561
+ }, async ({ messageIds, force, account }) => {
562
+ const args = ['gmail', 'batch', 'delete', ...messageIds];
563
+ if (force) args.push('--force');
564
+ return runOrDiagnose(args, { account });
192
565
  });
193
566
 
194
567
  server.registerTool('gog_gmail_batch_modify', {
@@ -248,12 +621,12 @@ export function registerExtraGmailTools(server: McpServer): void {
248
621
  });
249
622
 
250
623
  server.registerTool('gog_gmail_thread_attachments', {
251
- description: 'List all attachments in a Gmail thread, optionally downloading them.',
624
+ description: 'List all attachments in a Gmail thread, optionally downloading them. NOTE: download/outDir write to the CONNECTOR/gog server\'s filesystem — on the remote connector you can\'t read those files, so use gog_gmail_attachment per attachment to receive bytes (image inline, or a Drive link). This tool is best used just to LIST attachments (filenames, ids, sizes) and then fetch the ones you want individually.',
252
625
  annotations: { readOnlyHint: true },
253
626
  inputSchema: {
254
627
  threadId: z.string().describe('Gmail thread ID'),
255
- download: z.boolean().optional().describe('Download all attachments'),
256
- outDir: z.string().optional().describe('Directory to write attachments to (default: current directory)'),
628
+ download: z.boolean().optional().describe('Download all attachments to the SERVER filesystem (see the note above; on the remote connector the files aren\'t reachable — fetch individually with gog_gmail_attachment instead).'),
629
+ outDir: z.string().optional().describe('Directory to write attachments to, resolved on the gog SERVER\'s filesystem (default: current directory). Not your local machine on the remote connector.'),
257
630
  account: accountParam,
258
631
  },
259
632
  }, async ({ threadId, download, outDir, account }) => {
@@ -314,7 +687,7 @@ export function registerExtraGmailTools(server: McpServer): void {
314
687
  account: accountParam,
315
688
  },
316
689
  }, async ({ labelIdOrName, account }) => {
317
- return runOrDiagnose(['gmail', 'labels', 'delete', labelIdOrName], { account });
690
+ return runOrDiagnose(['gmail', 'labels', 'delete', labelIdOrName, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
318
691
  });
319
692
 
320
693
  server.registerTool('gog_gmail_labels_modify', {
@@ -369,12 +742,14 @@ export function registerExtraGmailTools(server: McpServer): void {
369
742
  cc: z.string().optional().describe('CC recipients (comma-separated)'),
370
743
  bcc: z.string().optional().describe('BCC recipients (comma-separated)'),
371
744
  subject: z.string().describe('Subject'),
372
- body: z.string().describe('Body (plain text)'),
373
- bodyHtml: z.string().optional().describe('Body (HTML; optional)'),
745
+ body: z.string().describe('Body (plain text). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
746
+ bodyHtml: z.string().optional().describe('Body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
747
+ bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server to use as the HTML body, or "-" to read from stdin. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
374
748
  replyToMessageId: z.string().optional().describe('Reply to a specific Gmail MESSAGE id — the short hex `id` field from gog_gmail_get / _search / _thread_get (e.g. 19e7593d77fd9636), NOT a thread id and NOT the RFC822 `<…@host>` Message-Id header. Anchors In-Reply-To/References to that exact message. To reply to a thread when you don\'t know the latest message, use replyToThreadId instead. If both are given, replyToMessageId wins.'),
375
749
  replyToThreadId: z.string().optional().describe('Reply to a Gmail THREAD id — passed to gog as --thread-id, which threads the draft using the thread\'s latest-message headers (In-Reply-To/References). This is what "reply to this thread" almost always means. Mutually exclusive with replyToMessageId (which wins if both are set). Thread ids and message ids are both 16-hex strings and easy to confuse — use this param, not replyToMessageId, when the id came from a thread.'),
376
750
  replyTo: z.string().optional().describe('Reply-To header address'),
377
751
  quote: z.boolean().optional().describe('Include quoted original message in reply (requires replyToMessageId or replyToThreadId)'),
752
+ replyAll: z.boolean().optional().describe('Auto-populate recipients from the original message (reply-all), inferring To/Cc from it. Requires replyToMessageId or replyToThreadId. Explicit to/cc/bcc still apply on top; omitRecipients still suppresses them.'),
378
753
  attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension. The JSON result echoes attached filenames and byte sizes — check it to confirm the files were found and embedded. On gog_gmail_drafts_update, supplying attach REPLACES the draft\'s existing attachments; omitting it preserves them (use clearAttachments to remove all).'),
379
754
  from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
380
755
  omitRecipients: z.boolean().optional().describe('Create the draft with no recipients even if to/cc/bcc are supplied — an accidental-send guard. Populate recipients in a later update before sending.'),
@@ -389,29 +764,37 @@ export function registerExtraGmailTools(server: McpServer): void {
389
764
  subject: string;
390
765
  body: string;
391
766
  bodyHtml?: string;
767
+ bodyHtmlFile?: string;
392
768
  replyToMessageId?: string;
393
769
  replyToThreadId?: string;
394
770
  replyTo?: string;
395
771
  quote?: boolean;
772
+ replyAll?: boolean;
396
773
  attach?: string[];
397
774
  from?: string;
398
775
  omitRecipients?: boolean;
399
776
  };
400
777
 
401
- function appendDraftFlags(args: string[], f: DraftFlags): void {
778
+ function appendDraftFlags(args: GogArg[], f: DraftFlags): void {
779
+ assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
402
780
  if (!f.omitRecipients) {
403
781
  if (f.to) args.push(`--to=${f.to}`);
404
782
  if (f.cc) args.push(`--cc=${f.cc}`);
405
783
  if (f.bcc) args.push(`--bcc=${f.bcc}`);
406
784
  }
407
785
  args.push(`--subject=${f.subject}`);
408
- args.push(`--body=${f.body}`);
409
- if (f.bodyHtml) args.push(`--body-html=${f.bodyHtml}`);
786
+ // body is required, so this is an either/or swap rather than an extra push:
787
+ // --body and --body-file together are a hard error in gog.
788
+ args.push(payloadArg('body', 'body-file', f.body));
789
+ if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
790
+ else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
410
791
  // A draft can reply to a specific message (--reply-to-message-id) or thread
411
792
  // off the latest message in a thread (--thread-id, which gog resolves
412
793
  // server-side). replyToMessageId wins when both are supplied.
413
794
  if (f.replyToMessageId) args.push(`--reply-to-message-id=${f.replyToMessageId}`);
414
795
  else if (f.replyToThreadId) args.push(`--thread-id=${f.replyToThreadId}`);
796
+ // --reply-all infers original recipients; gog requires a reply target above.
797
+ if (f.replyAll) args.push('--reply-all');
415
798
  if (f.replyTo) args.push(`--reply-to=${f.replyTo}`);
416
799
  if (f.quote) args.push('--quote');
417
800
  if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
@@ -424,20 +807,20 @@ export function registerExtraGmailTools(server: McpServer): void {
424
807
  // front; for creates it's read from the write response's draftId. Degrades to
425
808
  // the raw write result if the id can't be determined.
426
809
  async function writeDraft(
427
- args: string[],
810
+ args: GogArg[],
428
811
  account: string | undefined,
429
812
  returnFull: boolean | undefined,
430
813
  knownDraftId?: string,
431
- ): Promise<ToolResult> {
814
+ ): Promise<CallToolResult> {
432
815
  const result = await runOrDiagnose(args, { account });
433
816
  if (!returnFull) return result;
434
817
  // The write must have returned a JSON acknowledgement before we re-fetch.
435
- // A failed write (an error ToolResult, not JSON) is surfaced as-is rather
818
+ // A failed write (an error result, not JSON) is surfaced as-is rather
436
819
  // than masked by re-fetching the unchanged draft — this matters for the
437
820
  // update path, where a known draftId would otherwise re-fetch a stale draft.
438
821
  let parsed: { draftId?: string };
439
822
  try {
440
- parsed = JSON.parse(result.content[0].text) as { draftId?: string };
823
+ parsed = JSON.parse(resultText(result) ?? '') as { draftId?: string };
441
824
  } catch {
442
825
  return result;
443
826
  }
@@ -450,7 +833,7 @@ export function registerExtraGmailTools(server: McpServer): void {
450
833
  description: 'Create a new Gmail draft. Recipients (to/cc/bcc) are optional; omit them (or set omitRecipients) to create a recipient-less draft as an accidental-send guard. For replies, prefer replyToThreadId (anchors to the thread\'s latest message) or replyToMessageId (a specific message) — don\'t pass a thread id into replyToMessageId, which mis-threads silently.',
451
834
  inputSchema: draftWriteSchema,
452
835
  }, async ({ account, returnFull, ...flags }) => {
453
- const args = ['gmail', 'drafts', 'create'];
836
+ const args: GogArg[] = ['gmail', 'drafts', 'create'];
454
837
  appendDraftFlags(args, flags);
455
838
  return writeDraft(args, account, returnFull);
456
839
  });
@@ -464,7 +847,7 @@ export function registerExtraGmailTools(server: McpServer): void {
464
847
  clearAttachments: z.boolean().optional().describe('Remove all attachments from the draft. By default, omitting attach preserves the draft\'s existing attachments; this intentionally clears them. Ignored if attach is also supplied (attach replaces).'),
465
848
  },
466
849
  }, async ({ draftId, account, returnFull, clearAttachments, ...flags }) => {
467
- const args = ['gmail', 'drafts', 'update', draftId];
850
+ const args: GogArg[] = ['gmail', 'drafts', 'update', draftId];
468
851
  appendDraftFlags(args, flags);
469
852
  if (clearAttachments) args.push('--clear-attachments');
470
853
  return writeDraft(args, account, returnFull, draftId);
@@ -509,15 +892,95 @@ export function registerExtraGmailTools(server: McpServer): void {
509
892
  account: accountParam,
510
893
  },
511
894
  }, async ({ messageId, to, cc, bcc, note, from, skipAttachments, account }) => {
512
- const args = ['gmail', 'forward', messageId, `--to=${to}`];
895
+ const args: GogArg[] = ['gmail', 'forward', messageId, `--to=${to}`];
513
896
  if (cc) args.push(`--cc=${cc}`);
514
897
  if (bcc) args.push(`--bcc=${bcc}`);
515
- if (note) args.push(`--note=${note}`);
898
+ if (note) args.push(payloadArg('note', 'note-file', note));
516
899
  if (from) args.push(`--from=${from}`);
517
900
  if (skipAttachments) args.push('--skip-attachments');
518
901
  return runOrDiagnose(args, { account });
519
902
  });
520
903
 
904
+ // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
905
+ // only in the subcommand and default recipient set (reply → sender; reply-all
906
+ // → every participant). Recipient flags are repeatable on the CLI, so they are
907
+ // arrays here. --to/--cc/--bcc ADD or MOVE recipients onto the inherited reply
908
+ // set; --remove drops them. Body/HTML follow the same inline-or-file shape as
909
+ // the draft tools.
910
+ const replySchema = {
911
+ messageId: z.string().describe('Gmail message ID to reply to — the short hex `id` from gog_gmail_get / _search / _messages_search (NOT the threadId, NOT the RFC822 `<…@host>` Message-Id header).'),
912
+ body: z.string().optional().describe('Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
913
+ bodyHtml: z.string().optional().describe('Reply body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
914
+ bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body, or "-" for stdin. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
915
+ to: z.array(z.string()).optional().describe('Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message.'),
916
+ cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
917
+ bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
918
+ remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
919
+ subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
920
+ noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
921
+ attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension.'),
922
+ from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
923
+ signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
924
+ signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
925
+ signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
926
+ account: accountParam,
927
+ };
928
+
929
+ type ReplyFlags = {
930
+ body?: string;
931
+ bodyHtml?: string;
932
+ bodyHtmlFile?: string;
933
+ to?: string[];
934
+ cc?: string[];
935
+ bcc?: string[];
936
+ remove?: string[];
937
+ subject?: string;
938
+ noQuote?: boolean;
939
+ attach?: string[];
940
+ from?: string;
941
+ signature?: boolean;
942
+ signatureFrom?: string;
943
+ signatureFile?: string;
944
+ };
945
+
946
+ function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
947
+ assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
948
+ if (f.body) args.push(payloadArg('body', 'body-file', f.body));
949
+ if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
950
+ else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
951
+ if (f.to) for (const r of f.to) args.push(`--to=${r}`);
952
+ if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
953
+ if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
954
+ if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
955
+ if (f.subject) args.push(`--subject=${f.subject}`);
956
+ if (f.noQuote) args.push('--no-quote');
957
+ if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
958
+ if (f.from) args.push(`--from=${f.from}`);
959
+ if (f.signature) args.push('--signature');
960
+ if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
961
+ if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
962
+ }
963
+
964
+ server.registerTool('gog_gmail_reply', {
965
+ description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage a reply without sending use gog_gmail_drafts_create.',
966
+ annotations: { destructiveHint: true },
967
+ inputSchema: replySchema,
968
+ }, async ({ messageId, account, ...flags }) => {
969
+ const args: GogArg[] = ['gmail', 'reply', messageId];
970
+ appendReplyFlags(args, flags);
971
+ return runOrDiagnose(args, { account });
972
+ });
973
+
974
+ server.registerTool('gog_gmail_reply_all', {
975
+ description: 'Reply to all participants of a Gmail message (sender plus every To/Cc recipient). Same inherited "Re:" subject and quoting as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all.',
976
+ annotations: { destructiveHint: true },
977
+ inputSchema: replySchema,
978
+ }, async ({ messageId, account, ...flags }) => {
979
+ const args: GogArg[] = ['gmail', 'reply-all', messageId];
980
+ appendReplyFlags(args, flags);
981
+ return runOrDiagnose(args, { account });
982
+ });
983
+
521
984
  server.registerTool('gog_gmail_autoreply', {
522
985
  description: 'Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.',
523
986
  annotations: { destructiveHint: true },
@@ -537,10 +1000,13 @@ export function registerExtraGmailTools(server: McpServer): void {
537
1000
  account: accountParam,
538
1001
  },
539
1002
  }, async ({ query, max, subject, body, bodyHtml, from, replyTo, label, archive, markRead, skipBulk, allowSelf, account }) => {
540
- const args = ['gmail', 'autoreply', query];
1003
+ const args: GogArg[] = ['gmail', 'autoreply', query];
541
1004
  if (max !== undefined) args.push(`--max=${max}`);
542
1005
  if (subject) args.push(`--subject=${subject}`);
543
- if (body) args.push(`--body=${body}`);
1006
+ if (body) args.push(payloadArg('body', 'body-file', body));
1007
+ // `gmail autoreply` has --body-file but NO --body-html-file (verified against
1008
+ // gog 0.34.1), so an HTML autoreply body stays inline and is still bounded by
1009
+ // the runner's per-arg cap. Route it through payloadArg if gog ever adds one.
544
1010
  if (bodyHtml) args.push(`--body-html=${bodyHtml}`);
545
1011
  if (from) args.push(`--from=${from}`);
546
1012
  if (replyTo) args.push(`--reply-to=${replyTo}`);
@@ -624,6 +1090,8 @@ export function registerExtraGmailTools(server: McpServer): void {
624
1090
  if (enable) args.push('--enable');
625
1091
  if (disable) args.push('--disable');
626
1092
  if (subject) args.push(`--subject=${subject}`);
1093
+ // `gmail settings vacation update` exposes only --body — there is no
1094
+ // --body-file variant (verified against gog 0.34.1), so this stays inline.
627
1095
  if (body) args.push(`--body=${body}`);
628
1096
  if (start) args.push(`--start=${start}`);
629
1097
  if (end) args.push(`--end=${end}`);
@@ -687,7 +1155,7 @@ export function registerExtraGmailTools(server: McpServer): void {
687
1155
  if (important) args.push('--important');
688
1156
  if (trash) args.push('--trash');
689
1157
  if (neverSpam) args.push('--never-spam');
690
- if (forward) args.push(`--forward=${forward}`);
1158
+ if (forward) args.push(`--forward=${forward}`, '--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (forwarding filters only)
691
1159
  return runOrDiagnose(args, { account });
692
1160
  });
693
1161
 
@@ -699,7 +1167,7 @@ export function registerExtraGmailTools(server: McpServer): void {
699
1167
  account: accountParam,
700
1168
  },
701
1169
  }, async ({ filterId, account }) => {
702
- return runOrDiagnose(['gmail', 'settings', 'filters', 'delete', filterId], { account });
1170
+ return runOrDiagnose(['gmail', 'settings', 'filters', 'delete', filterId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
703
1171
  });
704
1172
 
705
1173
  server.registerTool('gog_gmail_sendas_list', {
@@ -772,7 +1240,7 @@ export function registerExtraGmailTools(server: McpServer): void {
772
1240
  account: accountParam,
773
1241
  },
774
1242
  }, async ({ email, account }) => {
775
- return runOrDiagnose(['gmail', 'settings', 'sendas', 'delete', email], { account });
1243
+ return runOrDiagnose(['gmail', 'settings', 'sendas', 'delete', email, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
776
1244
  });
777
1245
 
778
1246
  server.registerTool('gog_gmail_sendas_verify', {