gogcli-mcp-gmail 2.21.0 → 2.22.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/README.md +26 -4
- package/SKILL.md +24 -3
- package/dist/index.js +522 -75
- package/manifest.json +6 -2
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +201 -29
- package/tests/tools/attachment-index-resolve.test.ts +76 -0
- package/tests/tools/gmail-extra.test.ts +382 -46
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-gmail",
|
|
5
5
|
"display_name": "gogcli (Gmail)",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.22.0",
|
|
7
7
|
"description": "Extended Gmail for Claude via gogcli — auth + full Gmail support (threads, labels, drafts, attachments, forward, autoreply, bulk operations)",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"name": "gog_gmail_attachment",
|
|
114
|
-
"description": "Download a Gmail attachment and deliver its contents: inline (base64 image/resource) when within the
|
|
114
|
+
"description": "Download a Gmail attachment by 0-based attachmentIndex (or legacy attachmentId) and deliver its contents: inline (base64 image/resource) when within the inline limit, otherwise uploaded to Google Drive with a shareable link"
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"name": "gog_gmail_url",
|
|
@@ -209,6 +209,10 @@
|
|
|
209
209
|
"name": "gog_gmail_drafts_send",
|
|
210
210
|
"description": "Send an existing Gmail draft"
|
|
211
211
|
},
|
|
212
|
+
{
|
|
213
|
+
"name": "gog_gmail_import",
|
|
214
|
+
"description": "Import an RFC822/EML message into the mailbox (keeps its original headers and date; does not send)"
|
|
215
|
+
},
|
|
212
216
|
{
|
|
213
217
|
"name": "gog_gmail_forward",
|
|
214
218
|
"description": "Forward a Gmail message to new recipients"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-gmail",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp-gmail",
|
|
5
5
|
"description": "Extended Gmail MCP server via gogcli — auth + full Gmail support (threads, labels, drafts, attachments, forward, autoreply, bulk operations)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/src/tools/gmail-extra.ts
CHANGED
|
@@ -88,22 +88,42 @@ function trimThread(
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
91
|
+
// gog's own default for --inline-max-bytes (gmail_attachment.go:27,
|
|
92
|
+
// `default:"3145728"` at upstream-v0.35.0). Restated here so the wrapper can pin
|
|
93
|
+
// the flag on every call rather than let GOG_GMAIL_INLINE_MAX_BYTES decide.
|
|
94
|
+
const GOG_DEFAULT_INLINE_MAX_BYTES = 3145728;
|
|
95
|
+
|
|
96
|
+
// `gog gmail attachment --inline --json` emits base64 content when the attachment
|
|
97
|
+
// is within gog's inline cap (3 MiB by default, --inline-max-bytes), otherwise
|
|
98
|
+
// just the on-disk path plus a `reason` explaining the size fallback.
|
|
99
|
+
//
|
|
100
|
+
// `filename`/`mimeType` come from gog's own part lookup and are present whenever
|
|
101
|
+
// that lookup resolved the part — always in indexed mode, and by single-attachment
|
|
102
|
+
// fallback otherwise. They are absent when the opaque id missed against a message
|
|
103
|
+
// with several attachments, which is exactly the case resolveBySize exists for.
|
|
94
104
|
type InlineAttachment = {
|
|
95
105
|
path?: string;
|
|
96
106
|
bytes?: number;
|
|
97
107
|
cached?: boolean;
|
|
98
108
|
contentBase64?: string;
|
|
99
109
|
reason?: string;
|
|
110
|
+
filename?: string;
|
|
111
|
+
mimeType?: string;
|
|
100
112
|
};
|
|
101
113
|
|
|
102
114
|
// One entry from `gog gmail get --json` `.attachments[]`: the message part
|
|
103
|
-
// metadata,
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
115
|
+
// metadata, carrying the TRUE filename and MIME type. `size` is the key the
|
|
116
|
+
// legacy path matches on — Gmail's `attachmentId` is NOT stable across calls
|
|
117
|
+
// (see resolveBySize); `attachmentIndex` is, which is why resolveByIndex exists.
|
|
118
|
+
// The two ids are mutually exclusive in gog's output: indexed mode emits the
|
|
119
|
+
// index INSTEAD of the id (`attachmentId,omitempty`).
|
|
120
|
+
type AttachmentMeta = {
|
|
121
|
+
filename?: string;
|
|
122
|
+
mimeType?: string;
|
|
123
|
+
attachmentId?: string;
|
|
124
|
+
attachmentIndex?: number;
|
|
125
|
+
size?: number;
|
|
126
|
+
};
|
|
107
127
|
|
|
108
128
|
// MIME type by file extension — the download endpoint reports none, and the
|
|
109
129
|
// client needs it to render an inline image or label an embedded resource. Part
|
|
@@ -209,6 +229,49 @@ async function resolveBySize(
|
|
|
209
229
|
}
|
|
210
230
|
}
|
|
211
231
|
|
|
232
|
+
// Resolve the part metadata for a 0-based attachment INDEX (gog >= 0.35.0). Unlike
|
|
233
|
+
// resolveBySize this is deterministic and needs no guessing: each part carries the
|
|
234
|
+
// index gog assigned it, so the lookup matches that DECLARED field rather than the
|
|
235
|
+
// array slot it happens to occupy. `collectAttachments` sets AttachmentIndex = i,
|
|
236
|
+
// so the two agree today — but they are different promises, and resolving by
|
|
237
|
+
// position would name the download after the wrong part on the day they diverge
|
|
238
|
+
// (#252).
|
|
239
|
+
//
|
|
240
|
+
// It also runs BEFORE the download rather than after, so the real filename can be
|
|
241
|
+
// handed to gog as --name/--out instead of the provisional `attachment` basename.
|
|
242
|
+
// Same one extra call as resolveBySize — it replaces it, it does not add to it.
|
|
243
|
+
async function resolveByIndex(
|
|
244
|
+
messageId: string,
|
|
245
|
+
index: number,
|
|
246
|
+
account: string | undefined,
|
|
247
|
+
): Promise<AttachmentMeta | undefined> {
|
|
248
|
+
try {
|
|
249
|
+
const parsed = JSON.parse(
|
|
250
|
+
await run(['gmail', 'get', messageId, '--use-indexed-attachment-ids'], { account }),
|
|
251
|
+
) as { attachments?: AttachmentMeta[] };
|
|
252
|
+
const attachments = parsed.attachments;
|
|
253
|
+
if (!attachments) return undefined;
|
|
254
|
+
// Match the DECLARED index, not the array slot. gog assigns
|
|
255
|
+
// AttachmentIndex = i over the collected parts (gmail_attachments.go), so
|
|
256
|
+
// the two agree today — but they are different promises, and resolveBySize
|
|
257
|
+
// above already matches on a field rather than a position. If a listing
|
|
258
|
+
// ever arrives filtered or reordered, position resolution does not fail, it
|
|
259
|
+
// names the download after the WRONG part, which is worse than an error.
|
|
260
|
+
const declared = attachments.find((a) => a.attachmentIndex === index);
|
|
261
|
+
if (declared) return declared;
|
|
262
|
+
// Nothing declared an index — an older gog, or a listing fetched without
|
|
263
|
+
// --use-indexed-attachment-ids. Position is then the only reading of the
|
|
264
|
+
// caller's number, and it is the one gog itself would apply.
|
|
265
|
+
if (attachments.every((a) => a.attachmentIndex === undefined)) return attachments[index];
|
|
266
|
+
// Some parts declared an index and none matched: the caller asked for an
|
|
267
|
+
// index that is not in this message. Guessing by position here would be the
|
|
268
|
+
// exact silent mis-naming this function exists to avoid.
|
|
269
|
+
return undefined;
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
212
275
|
// A writable, ephemeral server-side output path. gog MkdirAll's the tree, and
|
|
213
276
|
// /tmp is writable on both the local host and the Fly backend AND is cleared when
|
|
214
277
|
// the machine stops — unlike gog's default (the gogcli config dir), which on the
|
|
@@ -223,10 +286,15 @@ function defaultOutPath(messageId: string, filename: string): string {
|
|
|
223
286
|
// leaking the full command line (opaque attachment token included) and a raw shell
|
|
224
287
|
// error such as `mkdir /home/claude: permission denied`. Keep only the stderr
|
|
225
288
|
// tail, with the ids redacted.
|
|
226
|
-
|
|
289
|
+
//
|
|
290
|
+
// `attachmentId` is undefined in indexed mode: the reference is then a small
|
|
291
|
+
// integer, which is neither secret nor safely substitutable — blind-replacing "0"
|
|
292
|
+
// would corrupt every number in the message.
|
|
293
|
+
function sanitizeAttachmentError(err: unknown, messageId: string, attachmentId: string | undefined): string {
|
|
227
294
|
let msg = err instanceof Error ? err.message : String(err);
|
|
228
295
|
msg = msg.replace(/^Command failed:.*(\n|$)/, ''); // drop the command echo line
|
|
229
|
-
msg = msg.split(attachmentId).join('<attachment>')
|
|
296
|
+
if (attachmentId) msg = msg.split(attachmentId).join('<attachment>');
|
|
297
|
+
msg = msg.split(messageId).join('<message>');
|
|
230
298
|
return msg.trim() || 'the download failed on the server';
|
|
231
299
|
}
|
|
232
300
|
|
|
@@ -333,9 +401,12 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
333
401
|
|
|
334
402
|
server.registerTool('gog_gmail_attachment', {
|
|
335
403
|
description:
|
|
336
|
-
'Download a Gmail attachment and deliver its contents so you can actually read them.
|
|
337
|
-
'
|
|
338
|
-
'
|
|
404
|
+
'Download a Gmail attachment and deliver its contents so you can actually read them. Identify the ' +
|
|
405
|
+
'attachment by attachmentIndex (preferred: the 0-based position from a listing fetched with ' +
|
|
406
|
+
'useIndexedAttachmentIds — stable, and it resolves the real name before the download) or by the legacy ' +
|
|
407
|
+
'opaque attachmentId. The real filename and MIME type are resolved from the message part metadata, so ' +
|
|
408
|
+
'the saved file and response are named correctly (e.g. Guest_Copy.pdf), never a generic *.bin. ' +
|
|
409
|
+
'deliver="auto" (default) is transport-aware: ' +
|
|
339
410
|
'images always come back as a native image block; anything else is delivered by the channel that works ' +
|
|
340
411
|
'on your transport — a readable server-side file PATH on local (stdio) clients that share the filesystem, ' +
|
|
341
412
|
'or a Google Drive link on the remote connector (whose backend filesystem you can\'t read, and which ' +
|
|
@@ -345,7 +416,9 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
345
416
|
'{path, fileName, mimeType, bytes}. Drive delivery creates a file in your Drive (blocked when GOG_READONLY is set).',
|
|
346
417
|
inputSchema: {
|
|
347
418
|
messageId: z.string().describe('Gmail message ID'),
|
|
348
|
-
attachmentId: z.string().describe('
|
|
419
|
+
attachmentId: z.string().optional().describe('The opaque attachment ID from a listing. Legacy addressing: Gmail re-issues a DIFFERENT id for the same part on every API call, so an id copied from an older listing can be stale. Prefer attachmentIndex. Exactly one of attachmentId / attachmentIndex is required.'),
|
|
420
|
+
attachmentIndex: z.number().int().nonnegative().optional().describe('The attachment\'s 0-based position in its message — the `attachmentIndex` field of a listing fetched with useIndexedAttachmentIds. Stable (a message\'s MIME structure does not change), so this is the reliable way to name an attachment. Exactly one of attachmentId / attachmentIndex is required. NOTE: it is per-MESSAGE — in gog_gmail_thread_attachments the array is flattened across the whole thread, so use each row\'s messageId + attachmentIndex, never its position in that flat list.'),
|
|
421
|
+
inlineMaxBytes: z.number().int().nonnegative().optional().describe('Byte ceiling under which gog embeds the attachment bytes rather than only writing the file. Defaults to gog\'s own 3145728, which this server pins explicitly on every call so an ambient GOG_GMAIL_INLINE_MAX_BYTES cannot change the answer. Raise it to inline something larger, lower it to force the file/Drive path.'),
|
|
349
422
|
deliver: z
|
|
350
423
|
.enum(['auto', 'inline', 'drive', 'off'])
|
|
351
424
|
.optional()
|
|
@@ -355,7 +428,20 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
355
428
|
driveFolder: z.string().optional().describe('Destination Google Drive folder ID for the uploaded copy (drive/auto delivery on the remote connector, or oversized attachments).'),
|
|
356
429
|
account: accountParam,
|
|
357
430
|
},
|
|
358
|
-
}, async ({ messageId, attachmentId, deliver = 'auto', out, name, driveFolder, account }) => {
|
|
431
|
+
}, async ({ messageId, attachmentId, attachmentIndex, deliver = 'auto', out, name, inlineMaxBytes, driveFolder, account }) => {
|
|
432
|
+
// gog reads the id and the index from the SAME positional argument and is told
|
|
433
|
+
// which shape it got by --use-indexed-attachment-ids, so the wrapper has to
|
|
434
|
+
// pick exactly one. Rejected here rather than by zod so the message can say
|
|
435
|
+
// which one to prefer and why.
|
|
436
|
+
if ((attachmentId === undefined) === (attachmentIndex === undefined)) {
|
|
437
|
+
return errorResult(
|
|
438
|
+
'Pass exactly one of attachmentId or attachmentIndex. Prefer attachmentIndex — the 0-based ' +
|
|
439
|
+
'`attachmentIndex` from a listing fetched with useIndexedAttachmentIds — because Gmail\'s opaque ' +
|
|
440
|
+
'attachmentId is not stable across API calls and a copied one may no longer resolve.',
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const indexed = attachmentIndex !== undefined;
|
|
444
|
+
const attachmentRef = indexed ? String(attachmentIndex) : attachmentId as string;
|
|
359
445
|
// On the remote connector, `run` forwards to the Fly backend and this store
|
|
360
446
|
// is set; on local stdio it is unset. It is the one signal that tells apart
|
|
361
447
|
// "the caller shares my filesystem" (stdio → deliver a path) from "the caller
|
|
@@ -371,6 +457,15 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
371
457
|
let filename = name ? sanitizeFilename(name) : undefined;
|
|
372
458
|
let mimeType: string | undefined = filename ? MIME_BY_EXT[extOf(filename)] : undefined;
|
|
373
459
|
|
|
460
|
+
// 1b. Indexed mode resolves the part metadata BEFORE the download, because
|
|
461
|
+
// an index identifies the part outright. The file is then written under
|
|
462
|
+
// its real name and the post-download size heuristic never runs.
|
|
463
|
+
if (indexed && !filename) {
|
|
464
|
+
const meta = await resolveByIndex(messageId, attachmentIndex, account);
|
|
465
|
+
if (meta?.filename) filename = sanitizeFilename(meta.filename);
|
|
466
|
+
if (meta?.mimeType) mimeType = meta.mimeType;
|
|
467
|
+
}
|
|
468
|
+
|
|
374
469
|
// 2. Choose the server-side output path. A caller `out` only makes sense on
|
|
375
470
|
// the local transport; on the connector it resolves on the backend the
|
|
376
471
|
// caller can't read, so ignore it (with a note) and use a temp path. The
|
|
@@ -395,15 +490,32 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
395
490
|
if (!mimeType && (deliver === 'auto' || deliver === 'inline')) {
|
|
396
491
|
needInline = true; // need the bytes to sniff the type
|
|
397
492
|
}
|
|
398
|
-
const args = ['gmail', 'attachment', messageId,
|
|
493
|
+
const args = ['gmail', 'attachment', messageId, attachmentRef];
|
|
494
|
+
// PIN the id-vs-index mode on every call. GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS
|
|
495
|
+
// in the host env would otherwise make gog parse an opaque attachmentId as an
|
|
496
|
+
// integer and hard-fail ("the attachment argument must be a 0-based index",
|
|
497
|
+
// reproduced against a v0.35.0 build). runner.ts strips only credential-shaped
|
|
498
|
+
// vars, and on the remote runner the child env belongs to a backend we do not
|
|
499
|
+
// control — an explicit flag is the only setting authoritative on both.
|
|
500
|
+
args.push(indexed ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
|
|
399
501
|
if (needInline) args.push('--inline');
|
|
502
|
+
// PINNED for the same reason as --use-indexed-attachment-ids above: gog declares
|
|
503
|
+
// this flag env:"GOG_GMAIL_INLINE_MAX_BYTES" (gmail_attachment.go:27), so an ambient
|
|
504
|
+
// value in the host — or in the remote runner's backend, whose env is not ours —
|
|
505
|
+
// would silently decide whether contentBase64 comes back at all. Restating gog's own
|
|
506
|
+
// default keeps the arg array the single authority on both transports.
|
|
507
|
+
args.push(`--inline-max-bytes=${inlineMaxBytes ?? GOG_DEFAULT_INLINE_MAX_BYTES}`);
|
|
400
508
|
args.push(`--out=${outPath}`, `--name=${filename ?? 'attachment'}`);
|
|
401
509
|
const info = JSON.parse(await run(args, { account })) as InlineAttachment;
|
|
402
510
|
const path = info.path ?? outPath;
|
|
403
511
|
|
|
404
|
-
// 4. Resolve the real filename/MIME when
|
|
405
|
-
// the
|
|
406
|
-
|
|
512
|
+
// 4. Resolve the real filename/MIME when it is still unknown. gog's own
|
|
513
|
+
// --inline response carries the part metadata whenever its lookup hit, so
|
|
514
|
+
// prefer that; the size heuristic is the last resort and applies only to
|
|
515
|
+
// the legacy id path (an index already resolved above).
|
|
516
|
+
if (!filename && info.filename) filename = sanitizeFilename(info.filename);
|
|
517
|
+
if (!mimeType && info.mimeType) mimeType = info.mimeType;
|
|
518
|
+
if (!filename && !indexed) {
|
|
407
519
|
const meta = await resolveBySize(messageId, info.bytes, account);
|
|
408
520
|
if (meta?.filename) filename = sanitizeFilename(meta.filename);
|
|
409
521
|
if (!mimeType && meta?.mimeType) mimeType = meta.mimeType;
|
|
@@ -434,7 +546,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
434
546
|
: inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
|
|
435
547
|
}
|
|
436
548
|
return errorResult(
|
|
437
|
-
`Attachment is too large to return inline (${info.reason ?? "exceeds gog's 3 MiB
|
|
549
|
+
`Attachment is too large to return inline (${info.reason ?? "exceeds gog's inline size limit, 3 MiB by default — raise inlineMaxBytes"}). ` +
|
|
438
550
|
'Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.',
|
|
439
551
|
);
|
|
440
552
|
}
|
|
@@ -450,7 +562,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
450
562
|
} catch (err) {
|
|
451
563
|
// Never surface gog's raw error (it echoes the full command line + attachment
|
|
452
564
|
// token on the backend); redact the ids and let diagnose classify the rest.
|
|
453
|
-
return diagnose(new Error(sanitizeAttachmentError(err, messageId, attachmentId)));
|
|
565
|
+
return diagnose(new Error(sanitizeAttachmentError(err, messageId, attachmentId))); // opaque id only; an index needs no redaction
|
|
454
566
|
}
|
|
455
567
|
});
|
|
456
568
|
|
|
@@ -591,15 +703,20 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
591
703
|
sanitizeContent: z.boolean().optional().describe('Strip HTML, remove URLs, omit raw payloads from JSON (largest payload-size reduction)'),
|
|
592
704
|
latestN: z.number().int().positive().optional().describe('Return only the most recent N messages in the thread (wrapper-side trim; avoids overflowing context on long threads)'),
|
|
593
705
|
snippetsOnly: z.boolean().optional().describe('Reduce each message to its id, labels, snippet, and key headers (From/To/Cc/Subject/Date), dropping full bodies'),
|
|
706
|
+
useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId`. The index is stable across calls (a message\'s MIME structure does not change) while the id is not, so this is what you want before calling gog_gmail_attachment.'),
|
|
594
707
|
outDir: z.string().optional().describe('Directory to write attachments to (default: current directory)'),
|
|
595
708
|
account: accountParam,
|
|
596
709
|
},
|
|
597
|
-
}, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, outDir, account }) => {
|
|
710
|
+
}, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, useIndexedAttachmentIds, outDir, account }) => {
|
|
598
711
|
const args = ['gmail', 'thread', 'get', threadId];
|
|
599
712
|
if (download) args.push('--download');
|
|
600
713
|
if (full) args.push('--full');
|
|
601
714
|
if (sanitizeContent) args.push('--sanitize-content');
|
|
602
715
|
if (outDir) args.push(`--out-dir=${outDir}`);
|
|
716
|
+
// PINNED, not conditional: GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS in the host env
|
|
717
|
+
// swaps `attachmentId` for `attachmentIndex` in every attachments[] entry. Only
|
|
718
|
+
// an explicit flag makes the response shape the same on both transports.
|
|
719
|
+
args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
|
|
603
720
|
const result = await runOrDiagnose(args, { account });
|
|
604
721
|
if (latestN === undefined && !snippetsOnly) return result;
|
|
605
722
|
return trimThread(result, latestN, snippetsOnly);
|
|
@@ -627,13 +744,18 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
627
744
|
inputSchema: {
|
|
628
745
|
threadId: z.string().describe('Gmail thread ID'),
|
|
629
746
|
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).'),
|
|
747
|
+
useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` instead of an opaque `attachmentId`. Set this before calling gog_gmail_attachment: the index is stable across calls, the id is not. The index counts WITHIN each message, and this listing flattens every message\'s attachments into one array — so pair each row\'s `messageId` with its own `attachmentIndex`; a row\'s position in the flat array is NOT the index.'),
|
|
630
748
|
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.'),
|
|
631
749
|
account: accountParam,
|
|
632
750
|
},
|
|
633
|
-
}, async ({ threadId, download, outDir, account }) => {
|
|
751
|
+
}, async ({ threadId, download, useIndexedAttachmentIds, outDir, account }) => {
|
|
634
752
|
const args = ['gmail', 'thread', 'attachments', threadId];
|
|
635
753
|
if (download) args.push('--download');
|
|
636
754
|
if (outDir) args.push(`--out-dir=${outDir}`);
|
|
755
|
+
// PINNED — see gog_gmail_thread_get. This listing is the one place the index is
|
|
756
|
+
// load-bearing: gog concatenates every message's attachments into a single
|
|
757
|
+
// array, so array position is NOT the per-message index the download expects.
|
|
758
|
+
args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
|
|
637
759
|
return runOrDiagnose(args, { account });
|
|
638
760
|
});
|
|
639
761
|
|
|
@@ -730,11 +852,13 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
730
852
|
inputSchema: {
|
|
731
853
|
draftId: z.string().describe('Draft ID'),
|
|
732
854
|
download: z.boolean().optional().describe('Download draft attachments'),
|
|
855
|
+
useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` instead of an opaque `attachmentId` (stable across calls, unlike the id).'),
|
|
733
856
|
account: accountParam,
|
|
734
857
|
},
|
|
735
|
-
}, async ({ draftId, download, account }) => {
|
|
858
|
+
}, async ({ draftId, download, useIndexedAttachmentIds, account }) => {
|
|
736
859
|
const args = ['gmail', 'drafts', 'get', draftId];
|
|
737
860
|
if (download) args.push('--download');
|
|
861
|
+
args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false'); // PINNED — see gog_gmail_thread_get
|
|
738
862
|
return runOrDiagnose(args, { account });
|
|
739
863
|
});
|
|
740
864
|
|
|
@@ -745,7 +869,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
745
869
|
subject: z.string().describe('Subject'),
|
|
746
870
|
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.'),
|
|
747
871
|
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.'),
|
|
748
|
-
bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server to use as the HTML body
|
|
872
|
+
bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server to use as the HTML body. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
|
|
749
873
|
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.'),
|
|
750
874
|
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.'),
|
|
751
875
|
replyTo: z.string().optional().describe('Reply-To header address'),
|
|
@@ -753,6 +877,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
753
877
|
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.'),
|
|
754
878
|
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).'),
|
|
755
879
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
880
|
+
autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
|
|
756
881
|
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.'),
|
|
757
882
|
returnFull: z.boolean().optional().describe('After writing, re-fetch and return the full stored draft (subject, body, recipients) instead of just the write acknowledgement. Costs one extra read.'),
|
|
758
883
|
account: accountParam,
|
|
@@ -773,6 +898,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
773
898
|
replyAll?: boolean;
|
|
774
899
|
attach?: string[];
|
|
775
900
|
from?: string;
|
|
901
|
+
autoFromAddressedAlias?: boolean;
|
|
776
902
|
omitRecipients?: boolean;
|
|
777
903
|
};
|
|
778
904
|
|
|
@@ -800,6 +926,11 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
800
926
|
if (f.quote) args.push('--quote');
|
|
801
927
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
802
928
|
if (f.from) args.push(`--from=${f.from}`);
|
|
929
|
+
// PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
|
|
930
|
+
// silently changes which address the mail goes out FROM, with nothing in the arg
|
|
931
|
+
// array to show for it — and the remote runner's backend env is not ours to set.
|
|
932
|
+
// An explicit flag is the only value authoritative on both transports.
|
|
933
|
+
args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
|
|
803
934
|
}
|
|
804
935
|
|
|
805
936
|
// Run a draft write, then — when returnFull is set — re-fetch the stored
|
|
@@ -827,7 +958,9 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
827
958
|
}
|
|
828
959
|
const draftId = knownDraftId ?? parsed.draftId;
|
|
829
960
|
if (!draftId) return result;
|
|
830
|
-
|
|
961
|
+
// Same PIN as gog_gmail_drafts_get — this re-fetch is handed to the caller
|
|
962
|
+
// verbatim, so its attachments[] shape must not depend on the host env.
|
|
963
|
+
return runOrDiagnose(['gmail', 'drafts', 'get', draftId, '--use-indexed-attachment-ids=false'], { account });
|
|
831
964
|
}
|
|
832
965
|
|
|
833
966
|
server.registerTool('gog_gmail_drafts_create', {
|
|
@@ -840,17 +973,19 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
840
973
|
});
|
|
841
974
|
|
|
842
975
|
server.registerTool('gog_gmail_drafts_update', {
|
|
843
|
-
description: 'Update an existing Gmail draft. For replies, prefer replyToThreadId (threads off the thread\'s latest message) or replyToMessageId (a specific message) over passing a thread id into replyToMessageId. Attachment semantics: supplying attach REPLACES the draft\'s existing attachments; omitting it preserves them; set clearAttachments to remove all.',
|
|
976
|
+
description: 'Update an existing Gmail draft. For replies, prefer replyToThreadId (threads off the thread\'s latest message) or replyToMessageId (a specific message) over passing a thread id into replyToMessageId. An update preserves the draft\'s existing reply context (In-Reply-To/References) and its threadId; it never invents reply headers for a draft that is not a reply. The result reports the effective inReplyTo/references so you can verify threading without a raw-header fetch. Attachment semantics: supplying attach REPLACES the draft\'s existing attachments; omitting it preserves them; set clearAttachments to remove all.',
|
|
844
977
|
annotations: { destructiveHint: true },
|
|
845
978
|
inputSchema: {
|
|
846
979
|
draftId: z.string().describe('Draft ID'),
|
|
847
980
|
...draftWriteSchema,
|
|
848
981
|
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).'),
|
|
982
|
+
clearReplyContext: z.boolean().optional().describe('Strip In-Reply-To/References from the draft, turning a reply back into a standalone message while keeping the same draft id and threadId. Use this to repair a mis-threaded draft in place instead of deleting and recreating it. Mutually exclusive with replyToMessageId, replyToThreadId and quote — gog rejects the call if any of them is combined with this.'),
|
|
849
983
|
},
|
|
850
|
-
}, async ({ draftId, account, returnFull, clearAttachments, ...flags }) => {
|
|
984
|
+
}, async ({ draftId, account, returnFull, clearAttachments, clearReplyContext, ...flags }) => {
|
|
851
985
|
const args: GogArg[] = ['gmail', 'drafts', 'update', draftId];
|
|
852
986
|
appendDraftFlags(args, flags);
|
|
853
987
|
if (clearAttachments) args.push('--clear-attachments');
|
|
988
|
+
if (clearReplyContext) args.push('--clear-reply-context');
|
|
854
989
|
return writeDraft(args, account, returnFull, draftId);
|
|
855
990
|
});
|
|
856
991
|
|
|
@@ -879,6 +1014,33 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
879
1014
|
return runOrDiagnose(['gmail', 'drafts', 'send', draftId], { account });
|
|
880
1015
|
});
|
|
881
1016
|
|
|
1017
|
+
server.registerTool('gog_gmail_import', {
|
|
1018
|
+
description:
|
|
1019
|
+
'Import an existing RFC822/EML message INTO the mailbox. This is Gmail\'s import path, not a send: nothing ' +
|
|
1020
|
+
'leaves the account, and the message keeps its own From/Date/Message-Id headers so it files where it ' +
|
|
1021
|
+
'belongs chronologically. Use it to restore an exported message or to file a .eml under a label; to ' +
|
|
1022
|
+
'actually send mail use gog_gmail_send, and to stage one use gog_gmail_drafts_create. The file is read on ' +
|
|
1023
|
+
'the gog SERVER, not your machine.',
|
|
1024
|
+
inputSchema: {
|
|
1025
|
+
file: z.string().describe('Path to an RFC822/EML file that ALREADY EXISTS on the gog server. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out.'),
|
|
1026
|
+
labels: z.array(z.string()).optional().describe('Labels to apply to the imported message (repeatable). Each may be a label ID or a label name — names are resolved server-side. A name containing a COMMA cannot be passed here: gog declares --label as a Kong slice with no separator override, so Kong splits each value on commas and "Clients, Inc" is looked up as two labels ("Clients" and "Inc") and fails. Use that label\'s ID instead — ids never contain a comma; gog_gmail_labels_list gives you one.'),
|
|
1027
|
+
internalDateSource: z.enum(['dateHeader', 'receivedTime']).optional().describe('Which clock sets Gmail\'s internal date: dateHeader (gog default — the message\'s own Date header, so it sorts into the mailbox at its original time) or receivedTime (now).'),
|
|
1028
|
+
neverMarkSpam: z.boolean().optional().describe('Never classify the imported message as spam.'),
|
|
1029
|
+
processForCalendar: z.boolean().optional().describe('Process calendar invitations inside the imported message — this can ADD EVENTS to your calendar.'),
|
|
1030
|
+
account: accountParam,
|
|
1031
|
+
},
|
|
1032
|
+
}, async ({ file, labels, internalDateSource, neverMarkSpam, processForCalendar, account }) => {
|
|
1033
|
+
// Not gated: gogcli's internal/cmd/gmail_import.go has no confirmDestructive /
|
|
1034
|
+
// dryRunAndConfirmDestructive call site (checked at upstream v0.35.0, and a live
|
|
1035
|
+
// `--dry-run` against a v0.35.0 build proceeds), so no --force is appended.
|
|
1036
|
+
const args = ['gmail', 'import', file];
|
|
1037
|
+
if (labels) for (const label of labels) args.push(`--label=${label}`);
|
|
1038
|
+
if (internalDateSource) args.push(`--internal-date-source=${internalDateSource}`);
|
|
1039
|
+
if (neverMarkSpam) args.push('--never-mark-spam');
|
|
1040
|
+
if (processForCalendar) args.push('--process-for-calendar');
|
|
1041
|
+
return runOrDiagnose(args, { account });
|
|
1042
|
+
});
|
|
1043
|
+
|
|
882
1044
|
server.registerTool('gog_gmail_forward', {
|
|
883
1045
|
description: 'Forward an existing Gmail message to new recipients.',
|
|
884
1046
|
annotations: { destructiveHint: true },
|
|
@@ -912,7 +1074,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
912
1074
|
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).'),
|
|
913
1075
|
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.'),
|
|
914
1076
|
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.'),
|
|
915
|
-
bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body
|
|
1077
|
+
bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
|
|
916
1078
|
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.'),
|
|
917
1079
|
cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
|
|
918
1080
|
bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
|
|
@@ -921,6 +1083,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
921
1083
|
noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
|
|
922
1084
|
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.'),
|
|
923
1085
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
1086
|
+
autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
|
|
924
1087
|
signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
|
|
925
1088
|
signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
|
|
926
1089
|
signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
|
|
@@ -939,6 +1102,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
939
1102
|
noQuote?: boolean;
|
|
940
1103
|
attach?: string[];
|
|
941
1104
|
from?: string;
|
|
1105
|
+
autoFromAddressedAlias?: boolean;
|
|
942
1106
|
signature?: boolean;
|
|
943
1107
|
signatureFrom?: string;
|
|
944
1108
|
signatureFile?: string;
|
|
@@ -960,6 +1124,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
960
1124
|
if (f.signature) args.push('--signature');
|
|
961
1125
|
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
962
1126
|
if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
|
|
1127
|
+
args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false'); // PINNED — see appendDraftFlags
|
|
963
1128
|
}
|
|
964
1129
|
|
|
965
1130
|
server.registerTool('gog_gmail_reply', {
|
|
@@ -1030,9 +1195,11 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
1030
1195
|
includeBody: z.boolean().optional().describe('Include the decoded message body in each result'),
|
|
1031
1196
|
full: z.boolean().optional().describe('Show full message bodies without truncation (implies includeBody)'),
|
|
1032
1197
|
bodyFormat: z.enum(['text', 'html']).optional().describe('Body format preference when includeBody is set'),
|
|
1198
|
+
includeAttachments: z.boolean().optional().describe('Include each message\'s attachment metadata (filename, size, mimeType, id or index). NOT a cheap add-on: like includeBody it makes gog fetch every matching message at format=full, so it costs a full per-message read — narrow the query or lower max before turning it on.'),
|
|
1199
|
+
useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId` (stable across calls, unlike the id). Only has an effect alongside includeAttachments or includeBody.'),
|
|
1033
1200
|
account: accountParam,
|
|
1034
1201
|
},
|
|
1035
|
-
}, async ({ query, max, page, all, includeBody, full, bodyFormat, account }) => {
|
|
1202
|
+
}, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
1036
1203
|
const args = ['gmail', 'messages', 'search', query];
|
|
1037
1204
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
1038
1205
|
if (page) args.push(`--page=${page}`);
|
|
@@ -1040,6 +1207,11 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
1040
1207
|
if (includeBody) args.push('--include-body');
|
|
1041
1208
|
if (full) args.push('--full');
|
|
1042
1209
|
if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
|
|
1210
|
+
// Both PINNED: GOG_GMAIL_INCLUDE_ATTACHMENTS and GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS
|
|
1211
|
+
// each change the result shape (and the first also the per-message API cost) with
|
|
1212
|
+
// nothing in the arg array to show for it. See gog_gmail_thread_get.
|
|
1213
|
+
args.push(includeAttachments ? '--include-attachments' : '--include-attachments=false');
|
|
1214
|
+
args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
|
|
1043
1215
|
return runOrDiagnose(args, { account });
|
|
1044
1216
|
});
|
|
1045
1217
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
|
|
3
|
+
|
|
4
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (orig) => {
|
|
5
|
+
const actual = await orig<Record<string, unknown>>();
|
|
6
|
+
return { ...actual, run: vi.fn() };
|
|
7
|
+
});
|
|
8
|
+
const { run } = await import('../../../gogcli-mcp/src/lib.js');
|
|
9
|
+
const { registerExtraGmailTools } = await import('../../src/tools/gmail-extra.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* #252: resolveByIndex took `attachments[index]` — array POSITION — while
|
|
13
|
+
* resolveBySize matches on a declared field. gog assigns AttachmentIndex = i
|
|
14
|
+
* today, so position happens to agree; the moment it does not (a filtered or
|
|
15
|
+
* reordered listing) the download is silently named after the WRONG part, which
|
|
16
|
+
* is worse than failing.
|
|
17
|
+
*
|
|
18
|
+
* The declared `attachmentIndex` is the contract. Match it.
|
|
19
|
+
*/
|
|
20
|
+
describe('resolveByIndex matches the declared attachmentIndex', () => {
|
|
21
|
+
let h: TestHarness;
|
|
22
|
+
beforeEach(async () => { vi.clearAllMocks(); h = await createTestHarness(registerExtraGmailTools); });
|
|
23
|
+
|
|
24
|
+
it('picks the part whose attachmentIndex equals the request, not its array slot', async () => {
|
|
25
|
+
(run as any).mockImplementation(async (args: string[]) => {
|
|
26
|
+
if (args[1] === 'get') {
|
|
27
|
+
// Declared indexes deliberately NOT in array order.
|
|
28
|
+
return JSON.stringify({ attachments: [
|
|
29
|
+
{ filename: 'second.pdf', attachmentIndex: 1, size: 100, mimeType: 'application/pdf' },
|
|
30
|
+
{ filename: 'first.pdf', attachmentIndex: 0, size: 200, mimeType: 'application/pdf' },
|
|
31
|
+
] });
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify({ path: '/tmp/x', size: 200 });
|
|
34
|
+
});
|
|
35
|
+
await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
|
|
36
|
+
const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
|
|
37
|
+
// index 0 is declared by the SECOND array element (first.pdf)
|
|
38
|
+
expect(dl.join(' ')).toContain('first.pdf');
|
|
39
|
+
expect(dl.join(' ')).not.toContain('second.pdf');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('still resolves when gog omits attachmentIndex, by position', async () => {
|
|
43
|
+
(run as any).mockImplementation(async (args: string[]) => {
|
|
44
|
+
if (args[1] === 'get') {
|
|
45
|
+
return JSON.stringify({ attachments: [
|
|
46
|
+
{ filename: 'a.pdf', size: 100, mimeType: 'application/pdf' },
|
|
47
|
+
{ filename: 'b.pdf', size: 200, mimeType: 'application/pdf' },
|
|
48
|
+
] });
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify({ path: '/tmp/x', size: 200 });
|
|
51
|
+
});
|
|
52
|
+
await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 1 });
|
|
53
|
+
const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
|
|
54
|
+
expect(dl.join(' ')).toContain('b.pdf');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('refuses to guess when indexes are declared but none matches', async () => {
|
|
58
|
+
// The caller asked for an index this message does not have. Falling back to
|
|
59
|
+
// position here would hand back a real-but-wrong attachment, which is the
|
|
60
|
+
// silent mis-naming this resolver exists to prevent — so it resolves to
|
|
61
|
+
// nothing and the download keeps its provisional name instead.
|
|
62
|
+
(run as any).mockImplementation(async (args: string[]) => {
|
|
63
|
+
if (args[1] === 'get') {
|
|
64
|
+
return JSON.stringify({ attachments: [
|
|
65
|
+
{ filename: 'a.pdf', attachmentIndex: 0, size: 100, mimeType: 'application/pdf' },
|
|
66
|
+
{ filename: 'b.pdf', attachmentIndex: 1, size: 200, mimeType: 'application/pdf' },
|
|
67
|
+
] });
|
|
68
|
+
}
|
|
69
|
+
return JSON.stringify({ path: '/tmp/x', size: 200 });
|
|
70
|
+
});
|
|
71
|
+
await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 7 });
|
|
72
|
+
const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
|
|
73
|
+
expect(dl.join(' ')).not.toContain('a.pdf');
|
|
74
|
+
expect(dl.join(' ')).not.toContain('b.pdf');
|
|
75
|
+
});
|
|
76
|
+
});
|