gogcli-mcp-gmail 2.8.0 → 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.
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/dist/index.js +20210 -19656
- package/manifest.json +23 -3
- package/package.json +3 -2
- package/src/index.ts +7 -9
- package/src/tools/gmail-extra.ts +498 -39
- package/tests/tools/gmail-extra.test.ts +720 -137
- package/tsconfig.json +1 -1
package/src/tools/gmail-extra.ts
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
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:
|
|
74
|
+
result: CallToolResult,
|
|
45
75
|
latestN: number | undefined,
|
|
46
76
|
snippetsOnly: boolean | undefined,
|
|
47
|
-
):
|
|
77
|
+
): CallToolResult {
|
|
48
78
|
try {
|
|
49
|
-
const parsed = JSON.parse(result
|
|
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
|
|
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:
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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', {
|
|
@@ -190,14 +551,17 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
190
551
|
});
|
|
191
552
|
|
|
192
553
|
server.registerTool('gog_gmail_batch_delete', {
|
|
193
|
-
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.',
|
|
194
555
|
annotations: { destructiveHint: true },
|
|
195
556
|
inputSchema: {
|
|
196
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.'),
|
|
197
559
|
account: accountParam,
|
|
198
560
|
},
|
|
199
|
-
}, async ({ messageIds, account }) => {
|
|
200
|
-
|
|
561
|
+
}, async ({ messageIds, force, account }) => {
|
|
562
|
+
const args = ['gmail', 'batch', 'delete', ...messageIds];
|
|
563
|
+
if (force) args.push('--force');
|
|
564
|
+
return runOrDiagnose(args, { account });
|
|
201
565
|
});
|
|
202
566
|
|
|
203
567
|
server.registerTool('gog_gmail_batch_modify', {
|
|
@@ -257,12 +621,12 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
257
621
|
});
|
|
258
622
|
|
|
259
623
|
server.registerTool('gog_gmail_thread_attachments', {
|
|
260
|
-
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.',
|
|
261
625
|
annotations: { readOnlyHint: true },
|
|
262
626
|
inputSchema: {
|
|
263
627
|
threadId: z.string().describe('Gmail thread ID'),
|
|
264
|
-
download: z.boolean().optional().describe('Download all attachments'),
|
|
265
|
-
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.'),
|
|
266
630
|
account: accountParam,
|
|
267
631
|
},
|
|
268
632
|
}, async ({ threadId, download, outDir, account }) => {
|
|
@@ -323,7 +687,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
323
687
|
account: accountParam,
|
|
324
688
|
},
|
|
325
689
|
}, async ({ labelIdOrName, account }) => {
|
|
326
|
-
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
|
|
327
691
|
});
|
|
328
692
|
|
|
329
693
|
server.registerTool('gog_gmail_labels_modify', {
|
|
@@ -378,12 +742,14 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
378
742
|
cc: z.string().optional().describe('CC recipients (comma-separated)'),
|
|
379
743
|
bcc: z.string().optional().describe('BCC recipients (comma-separated)'),
|
|
380
744
|
subject: z.string().describe('Subject'),
|
|
381
|
-
body: z.string().describe('Body (plain text)'),
|
|
382
|
-
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.'),
|
|
383
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.'),
|
|
384
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.'),
|
|
385
750
|
replyTo: z.string().optional().describe('Reply-To header address'),
|
|
386
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.'),
|
|
387
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).'),
|
|
388
754
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
389
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.'),
|
|
@@ -398,29 +764,37 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
398
764
|
subject: string;
|
|
399
765
|
body: string;
|
|
400
766
|
bodyHtml?: string;
|
|
767
|
+
bodyHtmlFile?: string;
|
|
401
768
|
replyToMessageId?: string;
|
|
402
769
|
replyToThreadId?: string;
|
|
403
770
|
replyTo?: string;
|
|
404
771
|
quote?: boolean;
|
|
772
|
+
replyAll?: boolean;
|
|
405
773
|
attach?: string[];
|
|
406
774
|
from?: string;
|
|
407
775
|
omitRecipients?: boolean;
|
|
408
776
|
};
|
|
409
777
|
|
|
410
|
-
function appendDraftFlags(args:
|
|
778
|
+
function appendDraftFlags(args: GogArg[], f: DraftFlags): void {
|
|
779
|
+
assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
|
|
411
780
|
if (!f.omitRecipients) {
|
|
412
781
|
if (f.to) args.push(`--to=${f.to}`);
|
|
413
782
|
if (f.cc) args.push(`--cc=${f.cc}`);
|
|
414
783
|
if (f.bcc) args.push(`--bcc=${f.bcc}`);
|
|
415
784
|
}
|
|
416
785
|
args.push(`--subject=${f.subject}`);
|
|
417
|
-
|
|
418
|
-
|
|
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}`);
|
|
419
791
|
// A draft can reply to a specific message (--reply-to-message-id) or thread
|
|
420
792
|
// off the latest message in a thread (--thread-id, which gog resolves
|
|
421
793
|
// server-side). replyToMessageId wins when both are supplied.
|
|
422
794
|
if (f.replyToMessageId) args.push(`--reply-to-message-id=${f.replyToMessageId}`);
|
|
423
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');
|
|
424
798
|
if (f.replyTo) args.push(`--reply-to=${f.replyTo}`);
|
|
425
799
|
if (f.quote) args.push('--quote');
|
|
426
800
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
@@ -433,20 +807,20 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
433
807
|
// front; for creates it's read from the write response's draftId. Degrades to
|
|
434
808
|
// the raw write result if the id can't be determined.
|
|
435
809
|
async function writeDraft(
|
|
436
|
-
args:
|
|
810
|
+
args: GogArg[],
|
|
437
811
|
account: string | undefined,
|
|
438
812
|
returnFull: boolean | undefined,
|
|
439
813
|
knownDraftId?: string,
|
|
440
|
-
): Promise<
|
|
814
|
+
): Promise<CallToolResult> {
|
|
441
815
|
const result = await runOrDiagnose(args, { account });
|
|
442
816
|
if (!returnFull) return result;
|
|
443
817
|
// The write must have returned a JSON acknowledgement before we re-fetch.
|
|
444
|
-
// A failed write (an error
|
|
818
|
+
// A failed write (an error result, not JSON) is surfaced as-is rather
|
|
445
819
|
// than masked by re-fetching the unchanged draft — this matters for the
|
|
446
820
|
// update path, where a known draftId would otherwise re-fetch a stale draft.
|
|
447
821
|
let parsed: { draftId?: string };
|
|
448
822
|
try {
|
|
449
|
-
parsed = JSON.parse(result
|
|
823
|
+
parsed = JSON.parse(resultText(result) ?? '') as { draftId?: string };
|
|
450
824
|
} catch {
|
|
451
825
|
return result;
|
|
452
826
|
}
|
|
@@ -459,7 +833,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
459
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.',
|
|
460
834
|
inputSchema: draftWriteSchema,
|
|
461
835
|
}, async ({ account, returnFull, ...flags }) => {
|
|
462
|
-
const args = ['gmail', 'drafts', 'create'];
|
|
836
|
+
const args: GogArg[] = ['gmail', 'drafts', 'create'];
|
|
463
837
|
appendDraftFlags(args, flags);
|
|
464
838
|
return writeDraft(args, account, returnFull);
|
|
465
839
|
});
|
|
@@ -473,7 +847,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
473
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).'),
|
|
474
848
|
},
|
|
475
849
|
}, async ({ draftId, account, returnFull, clearAttachments, ...flags }) => {
|
|
476
|
-
const args = ['gmail', 'drafts', 'update', draftId];
|
|
850
|
+
const args: GogArg[] = ['gmail', 'drafts', 'update', draftId];
|
|
477
851
|
appendDraftFlags(args, flags);
|
|
478
852
|
if (clearAttachments) args.push('--clear-attachments');
|
|
479
853
|
return writeDraft(args, account, returnFull, draftId);
|
|
@@ -518,15 +892,95 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
518
892
|
account: accountParam,
|
|
519
893
|
},
|
|
520
894
|
}, async ({ messageId, to, cc, bcc, note, from, skipAttachments, account }) => {
|
|
521
|
-
const args = ['gmail', 'forward', messageId, `--to=${to}`];
|
|
895
|
+
const args: GogArg[] = ['gmail', 'forward', messageId, `--to=${to}`];
|
|
522
896
|
if (cc) args.push(`--cc=${cc}`);
|
|
523
897
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
524
|
-
if (note) args.push(
|
|
898
|
+
if (note) args.push(payloadArg('note', 'note-file', note));
|
|
525
899
|
if (from) args.push(`--from=${from}`);
|
|
526
900
|
if (skipAttachments) args.push('--skip-attachments');
|
|
527
901
|
return runOrDiagnose(args, { account });
|
|
528
902
|
});
|
|
529
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
|
+
|
|
530
984
|
server.registerTool('gog_gmail_autoreply', {
|
|
531
985
|
description: 'Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.',
|
|
532
986
|
annotations: { destructiveHint: true },
|
|
@@ -546,10 +1000,13 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
546
1000
|
account: accountParam,
|
|
547
1001
|
},
|
|
548
1002
|
}, async ({ query, max, subject, body, bodyHtml, from, replyTo, label, archive, markRead, skipBulk, allowSelf, account }) => {
|
|
549
|
-
const args = ['gmail', 'autoreply', query];
|
|
1003
|
+
const args: GogArg[] = ['gmail', 'autoreply', query];
|
|
550
1004
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
551
1005
|
if (subject) args.push(`--subject=${subject}`);
|
|
552
|
-
if (body) args.push(
|
|
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.
|
|
553
1010
|
if (bodyHtml) args.push(`--body-html=${bodyHtml}`);
|
|
554
1011
|
if (from) args.push(`--from=${from}`);
|
|
555
1012
|
if (replyTo) args.push(`--reply-to=${replyTo}`);
|
|
@@ -633,6 +1090,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
633
1090
|
if (enable) args.push('--enable');
|
|
634
1091
|
if (disable) args.push('--disable');
|
|
635
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.
|
|
636
1095
|
if (body) args.push(`--body=${body}`);
|
|
637
1096
|
if (start) args.push(`--start=${start}`);
|
|
638
1097
|
if (end) args.push(`--end=${end}`);
|
|
@@ -696,7 +1155,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
696
1155
|
if (important) args.push('--important');
|
|
697
1156
|
if (trash) args.push('--trash');
|
|
698
1157
|
if (neverSpam) args.push('--never-spam');
|
|
699
|
-
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)
|
|
700
1159
|
return runOrDiagnose(args, { account });
|
|
701
1160
|
});
|
|
702
1161
|
|
|
@@ -708,7 +1167,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
708
1167
|
account: accountParam,
|
|
709
1168
|
},
|
|
710
1169
|
}, async ({ filterId, account }) => {
|
|
711
|
-
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
|
|
712
1171
|
});
|
|
713
1172
|
|
|
714
1173
|
server.registerTool('gog_gmail_sendas_list', {
|
|
@@ -781,7 +1240,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
781
1240
|
account: accountParam,
|
|
782
1241
|
},
|
|
783
1242
|
}, async ({ email, account }) => {
|
|
784
|
-
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
|
|
785
1244
|
});
|
|
786
1245
|
|
|
787
1246
|
server.registerTool('gog_gmail_sendas_verify', {
|