gogcli-mcp-gmail 2.0.11 → 2.2.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/CHANGELOG.md +19 -0
- package/dist/index.js +108 -32
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +112 -16
- package/tests/tools/gmail-extra.test.ts +161 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [2.1.0](https://github.com/chrischall/gogcli-mcp/compare/v2.0.13...v2.1.0) (2026-05-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **gmail:** add gogcli-mcp-gmail sub-package ([fac0f51](https://github.com/chrischall/gogcli-mcp/commit/fac0f515fd2ea587c570d7ac70c080cb8dc486ac))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Refactor
|
|
12
|
+
|
|
13
|
+
* aggressive cleanup pass (audit findings) ([e635b39](https://github.com/chrischall/gogcli-mcp/commit/e635b391cf611e9c102a422ffbce5ebe28687b80))
|
|
14
|
+
* review-pass cleanup (enums, shared schemas, test harness) ([ea851de](https://github.com/chrischall/gogcli-mcp/commit/ea851deb30b9cfef3f07ed506a011fc363b34018))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* update gogcli repo URLs to openclaw/gogcli (was steipete/gogcli) ([951a181](https://github.com/chrischall/gogcli-mcp/commit/951a1818ebc269f203aee723ccbea32c13817d8b))
|
package/dist/index.js
CHANGED
|
@@ -31074,7 +31074,7 @@ async function run(args, options = {}) {
|
|
|
31074
31074
|
|
|
31075
31075
|
// ../gogcli-mcp/src/tools/utils.ts
|
|
31076
31076
|
var accountParam = external_exports.string().optional().describe(
|
|
31077
|
-
"Google account email to use
|
|
31077
|
+
"Google account email to use, e.g. you@gmail.com \u2014 must be the full address, not a bare username. Overrides the GOG_ACCOUNT env var. Omit to use the single configured account."
|
|
31078
31078
|
);
|
|
31079
31079
|
var ids = {
|
|
31080
31080
|
course: external_exports.string().describe("Course ID"),
|
|
@@ -31133,26 +31133,41 @@ function toError(err) {
|
|
|
31133
31133
|
}
|
|
31134
31134
|
var AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
|
|
31135
31135
|
var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
|
|
31136
|
+
var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
|
|
31136
31137
|
var AUTH_HINT = "\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. Ask the user if they would like to re-authenticate.";
|
|
31137
31138
|
var TRANSIENT_HINT = "\n\nThis error is often transient. Retry the same call before trying a different approach (do not fall back to smaller writes or row-by-row operations).";
|
|
31139
|
+
var GRID_LIMIT_HINT = "\n\nThe target range is outside the sheet's current grid. Add the missing rows or columns first with gog_sheets_insert (dimension: rows or cols), then retry the write.";
|
|
31140
|
+
function formatAccountList(raw) {
|
|
31141
|
+
try {
|
|
31142
|
+
const parsed = JSON.parse(raw);
|
|
31143
|
+
if (Array.isArray(parsed?.accounts)) {
|
|
31144
|
+
return parsed.accounts.map((a) => a?.email).filter(Boolean).join("\n");
|
|
31145
|
+
}
|
|
31146
|
+
} catch {
|
|
31147
|
+
}
|
|
31148
|
+
return raw.trim();
|
|
31149
|
+
}
|
|
31150
|
+
async function diagnose(err) {
|
|
31151
|
+
const errText = toError(err).content[0].text;
|
|
31152
|
+
const isAuthError = AUTH_ERROR_PATTERN.test(errText);
|
|
31153
|
+
const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
31154
|
+
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
31155
|
+
const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
|
|
31156
|
+
try {
|
|
31157
|
+
const accounts = formatAccountList(await run(["auth", "list"]));
|
|
31158
|
+
return toText(`${errText}
|
|
31159
|
+
|
|
31160
|
+
Configured accounts:
|
|
31161
|
+
${accounts || "(none)"}${hint}`);
|
|
31162
|
+
} catch {
|
|
31163
|
+
return toText(`${errText}${hint}`);
|
|
31164
|
+
}
|
|
31165
|
+
}
|
|
31138
31166
|
async function runOrDiagnose(args, options) {
|
|
31139
31167
|
try {
|
|
31140
31168
|
return toText(await run(args, options));
|
|
31141
31169
|
} catch (err) {
|
|
31142
|
-
|
|
31143
|
-
const errText = base.content[0].text;
|
|
31144
|
-
const isAuthError = AUTH_ERROR_PATTERN.test(errText);
|
|
31145
|
-
const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
31146
|
-
const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : "";
|
|
31147
|
-
try {
|
|
31148
|
-
const accounts = await run(["auth", "list"]);
|
|
31149
|
-
return toText(`${errText}
|
|
31150
|
-
|
|
31151
|
-
Configured accounts:
|
|
31152
|
-
${accounts}${hint}`);
|
|
31153
|
-
} catch {
|
|
31154
|
-
return toText(`${errText}${hint}`);
|
|
31155
|
-
}
|
|
31170
|
+
return diagnose(err);
|
|
31156
31171
|
}
|
|
31157
31172
|
}
|
|
31158
31173
|
|
|
@@ -31221,7 +31236,7 @@ function registerAuthTools(server2) {
|
|
|
31221
31236
|
// ../gogcli-mcp/src/tools/gmail.ts
|
|
31222
31237
|
function registerGmailTools(server2) {
|
|
31223
31238
|
server2.registerTool("gog_gmail_search", {
|
|
31224
|
-
description:
|
|
31239
|
+
description: `Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail's own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).`,
|
|
31225
31240
|
annotations: { readOnlyHint: true },
|
|
31226
31241
|
inputSchema: {
|
|
31227
31242
|
query: external_exports.string().describe("Gmail search query"),
|
|
@@ -31272,9 +31287,15 @@ function registerGmailTools(server2) {
|
|
|
31272
31287
|
|
|
31273
31288
|
// ../gogcli-mcp/src/tools/sheets.ts
|
|
31274
31289
|
var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
|
|
31290
|
+
var dryRunParam = external_exports.boolean().optional().describe(
|
|
31291
|
+
"Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
|
|
31292
|
+
);
|
|
31293
|
+
var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
31294
|
+
'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.'
|
|
31295
|
+
);
|
|
31275
31296
|
|
|
31276
31297
|
// ../gogcli-mcp/src/server.ts
|
|
31277
|
-
var VERSION = true ? "2.0
|
|
31298
|
+
var VERSION = true ? "2.2.0" : "0.0.0";
|
|
31278
31299
|
function createServer(options) {
|
|
31279
31300
|
return new McpServer({
|
|
31280
31301
|
name: options?.name ?? "gogcli",
|
|
@@ -31283,6 +31304,37 @@ function createServer(options) {
|
|
|
31283
31304
|
}
|
|
31284
31305
|
|
|
31285
31306
|
// src/tools/gmail-extra.ts
|
|
31307
|
+
var SNIPPET_HEADERS = ["From", "To", "Cc", "Subject", "Date"];
|
|
31308
|
+
function summarizeMessage(m) {
|
|
31309
|
+
const rawHeaders = m.payload?.headers;
|
|
31310
|
+
const headers = {};
|
|
31311
|
+
if (Array.isArray(rawHeaders)) {
|
|
31312
|
+
for (const h of rawHeaders) {
|
|
31313
|
+
if (h.name && SNIPPET_HEADERS.includes(h.name)) headers[h.name] = h.value;
|
|
31314
|
+
}
|
|
31315
|
+
}
|
|
31316
|
+
return {
|
|
31317
|
+
id: m.id,
|
|
31318
|
+
threadId: m.threadId,
|
|
31319
|
+
internalDate: m.internalDate,
|
|
31320
|
+
labelIds: m.labelIds,
|
|
31321
|
+
snippet: m.snippet,
|
|
31322
|
+
headers
|
|
31323
|
+
};
|
|
31324
|
+
}
|
|
31325
|
+
function trimThread(result, latestN, snippetsOnly) {
|
|
31326
|
+
try {
|
|
31327
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
31328
|
+
const messages = parsed.thread?.messages;
|
|
31329
|
+
if (!Array.isArray(messages)) return result;
|
|
31330
|
+
let trimmed = messages;
|
|
31331
|
+
if (latestN !== void 0) trimmed = trimmed.slice(-latestN);
|
|
31332
|
+
if (snippetsOnly) trimmed = trimmed.map((m) => summarizeMessage(m));
|
|
31333
|
+
return toText(JSON.stringify({ ...parsed, thread: { ...parsed.thread, messages: trimmed } }));
|
|
31334
|
+
} catch {
|
|
31335
|
+
return result;
|
|
31336
|
+
}
|
|
31337
|
+
}
|
|
31286
31338
|
function registerExtraGmailTools(server2) {
|
|
31287
31339
|
server2.registerTool("gog_gmail_raw", {
|
|
31288
31340
|
description: "Dump the raw Gmail API response as JSON (lossless; for scripting and LLM consumption).",
|
|
@@ -31424,23 +31476,27 @@ function registerExtraGmailTools(server2) {
|
|
|
31424
31476
|
return runOrDiagnose(args, { account });
|
|
31425
31477
|
});
|
|
31426
31478
|
server2.registerTool("gog_gmail_thread_get", {
|
|
31427
|
-
description: "Get a Gmail thread with all messages,
|
|
31479
|
+
description: "Get a Gmail thread with all messages. For long threads that overflow context, use latestN to fetch only the most recent messages and/or snippetsOnly for a lightweight per-message headers+snippet view; sanitizeContent strips raw payloads/HTML and is the biggest size reducer when you do need bodies.",
|
|
31428
31480
|
annotations: { readOnlyHint: true },
|
|
31429
31481
|
inputSchema: {
|
|
31430
31482
|
threadId: external_exports.string().describe("Gmail thread ID"),
|
|
31431
31483
|
download: external_exports.boolean().optional().describe("Download all attachments"),
|
|
31432
31484
|
full: external_exports.boolean().optional().describe("Show full message bodies"),
|
|
31433
|
-
sanitizeContent: external_exports.boolean().optional().describe("Strip HTML, remove URLs, omit raw payloads from JSON"),
|
|
31485
|
+
sanitizeContent: external_exports.boolean().optional().describe("Strip HTML, remove URLs, omit raw payloads from JSON (largest payload-size reduction)"),
|
|
31486
|
+
latestN: external_exports.number().int().positive().optional().describe("Return only the most recent N messages in the thread (wrapper-side trim; avoids overflowing context on long threads)"),
|
|
31487
|
+
snippetsOnly: external_exports.boolean().optional().describe("Reduce each message to its id, labels, snippet, and key headers (From/To/Cc/Subject/Date), dropping full bodies"),
|
|
31434
31488
|
outDir: external_exports.string().optional().describe("Directory to write attachments to (default: current directory)"),
|
|
31435
31489
|
account: accountParam
|
|
31436
31490
|
}
|
|
31437
|
-
}, async ({ threadId, download, full, sanitizeContent, outDir, account }) => {
|
|
31491
|
+
}, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, outDir, account }) => {
|
|
31438
31492
|
const args = ["gmail", "thread", "get", threadId];
|
|
31439
31493
|
if (download) args.push("--download");
|
|
31440
31494
|
if (full) args.push("--full");
|
|
31441
31495
|
if (sanitizeContent) args.push("--sanitize-content");
|
|
31442
31496
|
if (outDir) args.push(`--out-dir=${outDir}`);
|
|
31443
|
-
|
|
31497
|
+
const result = await runOrDiagnose(args, { account });
|
|
31498
|
+
if (latestN === void 0 && !snippetsOnly) return result;
|
|
31499
|
+
return trimThread(result, latestN, snippetsOnly);
|
|
31444
31500
|
});
|
|
31445
31501
|
server2.registerTool("gog_gmail_thread_modify", {
|
|
31446
31502
|
description: "Modify labels on all messages in a thread (add and/or remove labels).",
|
|
@@ -31577,12 +31633,16 @@ function registerExtraGmailTools(server2) {
|
|
|
31577
31633
|
quote: external_exports.boolean().optional().describe("Include quoted original message in reply (requires replyToMessageId)"),
|
|
31578
31634
|
attach: external_exports.array(external_exports.string()).optional().describe("Attachment file paths (repeatable)"),
|
|
31579
31635
|
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
31636
|
+
omitRecipients: external_exports.boolean().optional().describe("Create the draft with no recipients even if to/cc/bcc are supplied \u2014 an accidental-send guard. Populate recipients in a later update before sending."),
|
|
31637
|
+
returnFull: external_exports.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."),
|
|
31580
31638
|
account: accountParam
|
|
31581
31639
|
};
|
|
31582
31640
|
function appendDraftFlags(args, f) {
|
|
31583
|
-
if (f.
|
|
31584
|
-
|
|
31585
|
-
|
|
31641
|
+
if (!f.omitRecipients) {
|
|
31642
|
+
if (f.to) args.push(`--to=${f.to}`);
|
|
31643
|
+
if (f.cc) args.push(`--cc=${f.cc}`);
|
|
31644
|
+
if (f.bcc) args.push(`--bcc=${f.bcc}`);
|
|
31645
|
+
}
|
|
31586
31646
|
args.push(`--subject=${f.subject}`);
|
|
31587
31647
|
args.push(`--body=${f.body}`);
|
|
31588
31648
|
if (f.bodyHtml) args.push(`--body-html=${f.bodyHtml}`);
|
|
@@ -31592,13 +31652,26 @@ function registerExtraGmailTools(server2) {
|
|
|
31592
31652
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
31593
31653
|
if (f.from) args.push(`--from=${f.from}`);
|
|
31594
31654
|
}
|
|
31655
|
+
async function writeDraft(args, account, returnFull, knownDraftId) {
|
|
31656
|
+
const result = await runOrDiagnose(args, { account });
|
|
31657
|
+
if (!returnFull) return result;
|
|
31658
|
+
let parsed;
|
|
31659
|
+
try {
|
|
31660
|
+
parsed = JSON.parse(result.content[0].text);
|
|
31661
|
+
} catch {
|
|
31662
|
+
return result;
|
|
31663
|
+
}
|
|
31664
|
+
const draftId = knownDraftId ?? parsed.draftId;
|
|
31665
|
+
if (!draftId) return result;
|
|
31666
|
+
return runOrDiagnose(["gmail", "drafts", "get", draftId], { account });
|
|
31667
|
+
}
|
|
31595
31668
|
server2.registerTool("gog_gmail_drafts_create", {
|
|
31596
|
-
description: "Create a new Gmail draft.",
|
|
31669
|
+
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.",
|
|
31597
31670
|
inputSchema: draftWriteSchema
|
|
31598
|
-
}, async ({ account, ...flags }) => {
|
|
31671
|
+
}, async ({ account, returnFull, ...flags }) => {
|
|
31599
31672
|
const args = ["gmail", "drafts", "create"];
|
|
31600
31673
|
appendDraftFlags(args, flags);
|
|
31601
|
-
return
|
|
31674
|
+
return writeDraft(args, account, returnFull);
|
|
31602
31675
|
});
|
|
31603
31676
|
server2.registerTool("gog_gmail_drafts_update", {
|
|
31604
31677
|
description: "Update an existing Gmail draft.",
|
|
@@ -31607,20 +31680,23 @@ function registerExtraGmailTools(server2) {
|
|
|
31607
31680
|
draftId: external_exports.string().describe("Draft ID"),
|
|
31608
31681
|
...draftWriteSchema
|
|
31609
31682
|
}
|
|
31610
|
-
}, async ({ draftId, account, ...flags }) => {
|
|
31683
|
+
}, async ({ draftId, account, returnFull, ...flags }) => {
|
|
31611
31684
|
const args = ["gmail", "drafts", "update", draftId];
|
|
31612
31685
|
appendDraftFlags(args, flags);
|
|
31613
|
-
return
|
|
31686
|
+
return writeDraft(args, account, returnFull, draftId);
|
|
31614
31687
|
});
|
|
31615
31688
|
server2.registerTool("gog_gmail_drafts_delete", {
|
|
31616
|
-
description: "
|
|
31689
|
+
description: "Permanently delete a Gmail draft (not reversible \u2014 drafts do not go to Trash). Requires force:true to delete non-interactively.",
|
|
31617
31690
|
annotations: { destructiveHint: true },
|
|
31618
31691
|
inputSchema: {
|
|
31619
31692
|
draftId: external_exports.string().describe("Draft ID"),
|
|
31693
|
+
force: external_exports.boolean().optional().describe("Required to delete in this non-interactive context \u2014 without it the delete is refused as a safety guard."),
|
|
31620
31694
|
account: accountParam
|
|
31621
31695
|
}
|
|
31622
|
-
}, async ({ draftId, account }) => {
|
|
31623
|
-
|
|
31696
|
+
}, async ({ draftId, account, force }) => {
|
|
31697
|
+
const args = ["gmail", "drafts", "delete", draftId];
|
|
31698
|
+
if (force) args.push("--force");
|
|
31699
|
+
return runOrDiagnose(args, { account });
|
|
31624
31700
|
});
|
|
31625
31701
|
server2.registerTool("gog_gmail_drafts_send", {
|
|
31626
31702
|
description: "Send an existing Gmail draft.",
|
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.0
|
|
6
|
+
"version": "2.2.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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-gmail",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.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
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { accountParam, runOrDiagnose } from '../../../gogcli-mcp/src/lib.js';
|
|
3
|
+
import { accountParam, runOrDiagnose, toText, type ToolResult } from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
|
|
5
|
+
type GmailHeader = { name?: string; value?: string };
|
|
6
|
+
type GmailMessage = {
|
|
7
|
+
id?: string;
|
|
8
|
+
threadId?: string;
|
|
9
|
+
internalDate?: string;
|
|
10
|
+
labelIds?: string[];
|
|
11
|
+
snippet?: string;
|
|
12
|
+
payload?: { headers?: GmailHeader[] };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Headers worth keeping in a snippets-only thread view.
|
|
16
|
+
const SNIPPET_HEADERS = ['From', 'To', 'Cc', 'Subject', 'Date'];
|
|
17
|
+
|
|
18
|
+
// Reduce a full Gmail message to a lightweight overview: id/labels/snippet plus
|
|
19
|
+
// the key envelope headers, dropping the raw MIME payload that dominates the
|
|
20
|
+
// size of a thread fetch.
|
|
21
|
+
function summarizeMessage(m: GmailMessage): Record<string, unknown> {
|
|
22
|
+
const rawHeaders = m.payload?.headers;
|
|
23
|
+
const headers: Record<string, string | undefined> = {};
|
|
24
|
+
if (Array.isArray(rawHeaders)) {
|
|
25
|
+
for (const h of rawHeaders) {
|
|
26
|
+
if (h.name && SNIPPET_HEADERS.includes(h.name)) headers[h.name] = h.value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
id: m.id,
|
|
31
|
+
threadId: m.threadId,
|
|
32
|
+
internalDate: m.internalDate,
|
|
33
|
+
labelIds: m.labelIds,
|
|
34
|
+
snippet: m.snippet,
|
|
35
|
+
headers,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Wrapper-side trim of a `gog gmail thread get` JSON result: keep only the last
|
|
40
|
+
// `latestN` messages and/or reduce each to a snippet view. gog has no native
|
|
41
|
+
// message-limit flag, so this is done by post-processing its output. Any
|
|
42
|
+
// non-JSON output (an error, an unexpected shape) is passed through untouched.
|
|
43
|
+
function trimThread(
|
|
44
|
+
result: ToolResult,
|
|
45
|
+
latestN: number | undefined,
|
|
46
|
+
snippetsOnly: boolean | undefined,
|
|
47
|
+
): ToolResult {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(result.content[0].text) as { thread?: { messages?: unknown[] } };
|
|
50
|
+
const messages = parsed.thread?.messages;
|
|
51
|
+
if (!Array.isArray(messages)) return result;
|
|
52
|
+
let trimmed: unknown[] = messages;
|
|
53
|
+
if (latestN !== undefined) trimmed = trimmed.slice(-latestN);
|
|
54
|
+
if (snippetsOnly) trimmed = trimmed.map((m) => summarizeMessage(m as GmailMessage));
|
|
55
|
+
return toText(JSON.stringify({ ...parsed, thread: { ...parsed.thread, messages: trimmed } }));
|
|
56
|
+
} catch {
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
4
60
|
|
|
5
61
|
export function registerExtraGmailTools(server: McpServer): void {
|
|
6
62
|
server.registerTool('gog_gmail_raw', {
|
|
@@ -152,23 +208,27 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
152
208
|
});
|
|
153
209
|
|
|
154
210
|
server.registerTool('gog_gmail_thread_get', {
|
|
155
|
-
description: 'Get a Gmail thread with all messages,
|
|
211
|
+
description: 'Get a Gmail thread with all messages. For long threads that overflow context, use latestN to fetch only the most recent messages and/or snippetsOnly for a lightweight per-message headers+snippet view; sanitizeContent strips raw payloads/HTML and is the biggest size reducer when you do need bodies.',
|
|
156
212
|
annotations: { readOnlyHint: true },
|
|
157
213
|
inputSchema: {
|
|
158
214
|
threadId: z.string().describe('Gmail thread ID'),
|
|
159
215
|
download: z.boolean().optional().describe('Download all attachments'),
|
|
160
216
|
full: z.boolean().optional().describe('Show full message bodies'),
|
|
161
|
-
sanitizeContent: z.boolean().optional().describe('Strip HTML, remove URLs, omit raw payloads from JSON'),
|
|
217
|
+
sanitizeContent: z.boolean().optional().describe('Strip HTML, remove URLs, omit raw payloads from JSON (largest payload-size reduction)'),
|
|
218
|
+
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)'),
|
|
219
|
+
snippetsOnly: z.boolean().optional().describe('Reduce each message to its id, labels, snippet, and key headers (From/To/Cc/Subject/Date), dropping full bodies'),
|
|
162
220
|
outDir: z.string().optional().describe('Directory to write attachments to (default: current directory)'),
|
|
163
221
|
account: accountParam,
|
|
164
222
|
},
|
|
165
|
-
}, async ({ threadId, download, full, sanitizeContent, outDir, account }) => {
|
|
223
|
+
}, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, outDir, account }) => {
|
|
166
224
|
const args = ['gmail', 'thread', 'get', threadId];
|
|
167
225
|
if (download) args.push('--download');
|
|
168
226
|
if (full) args.push('--full');
|
|
169
227
|
if (sanitizeContent) args.push('--sanitize-content');
|
|
170
228
|
if (outDir) args.push(`--out-dir=${outDir}`);
|
|
171
|
-
|
|
229
|
+
const result = await runOrDiagnose(args, { account });
|
|
230
|
+
if (latestN === undefined && !snippetsOnly) return result;
|
|
231
|
+
return trimThread(result, latestN, snippetsOnly);
|
|
172
232
|
});
|
|
173
233
|
|
|
174
234
|
server.registerTool('gog_gmail_thread_modify', {
|
|
@@ -316,6 +376,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
316
376
|
quote: z.boolean().optional().describe('Include quoted original message in reply (requires replyToMessageId)'),
|
|
317
377
|
attach: z.array(z.string()).optional().describe('Attachment file paths (repeatable)'),
|
|
318
378
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
379
|
+
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.'),
|
|
380
|
+
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.'),
|
|
319
381
|
account: accountParam,
|
|
320
382
|
};
|
|
321
383
|
|
|
@@ -331,12 +393,15 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
331
393
|
quote?: boolean;
|
|
332
394
|
attach?: string[];
|
|
333
395
|
from?: string;
|
|
396
|
+
omitRecipients?: boolean;
|
|
334
397
|
};
|
|
335
398
|
|
|
336
399
|
function appendDraftFlags(args: string[], f: DraftFlags): void {
|
|
337
|
-
if (f.
|
|
338
|
-
|
|
339
|
-
|
|
400
|
+
if (!f.omitRecipients) {
|
|
401
|
+
if (f.to) args.push(`--to=${f.to}`);
|
|
402
|
+
if (f.cc) args.push(`--cc=${f.cc}`);
|
|
403
|
+
if (f.bcc) args.push(`--bcc=${f.bcc}`);
|
|
404
|
+
}
|
|
340
405
|
args.push(`--subject=${f.subject}`);
|
|
341
406
|
args.push(`--body=${f.body}`);
|
|
342
407
|
if (f.bodyHtml) args.push(`--body-html=${f.bodyHtml}`);
|
|
@@ -347,13 +412,41 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
347
412
|
if (f.from) args.push(`--from=${f.from}`);
|
|
348
413
|
}
|
|
349
414
|
|
|
415
|
+
// Run a draft write, then — when returnFull is set — re-fetch the stored
|
|
416
|
+
// draft so the caller can verify subject/body/recipients persisted without a
|
|
417
|
+
// separate gog_gmail_drafts_get round trip. For updates the id is known up
|
|
418
|
+
// front; for creates it's read from the write response's draftId. Degrades to
|
|
419
|
+
// the raw write result if the id can't be determined.
|
|
420
|
+
async function writeDraft(
|
|
421
|
+
args: string[],
|
|
422
|
+
account: string | undefined,
|
|
423
|
+
returnFull: boolean | undefined,
|
|
424
|
+
knownDraftId?: string,
|
|
425
|
+
): Promise<ToolResult> {
|
|
426
|
+
const result = await runOrDiagnose(args, { account });
|
|
427
|
+
if (!returnFull) return result;
|
|
428
|
+
// The write must have returned a JSON acknowledgement before we re-fetch.
|
|
429
|
+
// A failed write (an error ToolResult, not JSON) is surfaced as-is rather
|
|
430
|
+
// than masked by re-fetching the unchanged draft — this matters for the
|
|
431
|
+
// update path, where a known draftId would otherwise re-fetch a stale draft.
|
|
432
|
+
let parsed: { draftId?: string };
|
|
433
|
+
try {
|
|
434
|
+
parsed = JSON.parse(result.content[0].text) as { draftId?: string };
|
|
435
|
+
} catch {
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
const draftId = knownDraftId ?? parsed.draftId;
|
|
439
|
+
if (!draftId) return result;
|
|
440
|
+
return runOrDiagnose(['gmail', 'drafts', 'get', draftId], { account });
|
|
441
|
+
}
|
|
442
|
+
|
|
350
443
|
server.registerTool('gog_gmail_drafts_create', {
|
|
351
|
-
description: 'Create a new Gmail draft.',
|
|
444
|
+
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.',
|
|
352
445
|
inputSchema: draftWriteSchema,
|
|
353
|
-
}, async ({ account, ...flags }) => {
|
|
446
|
+
}, async ({ account, returnFull, ...flags }) => {
|
|
354
447
|
const args = ['gmail', 'drafts', 'create'];
|
|
355
448
|
appendDraftFlags(args, flags);
|
|
356
|
-
return
|
|
449
|
+
return writeDraft(args, account, returnFull);
|
|
357
450
|
});
|
|
358
451
|
|
|
359
452
|
server.registerTool('gog_gmail_drafts_update', {
|
|
@@ -363,21 +456,24 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
363
456
|
draftId: z.string().describe('Draft ID'),
|
|
364
457
|
...draftWriteSchema,
|
|
365
458
|
},
|
|
366
|
-
}, async ({ draftId, account, ...flags }) => {
|
|
459
|
+
}, async ({ draftId, account, returnFull, ...flags }) => {
|
|
367
460
|
const args = ['gmail', 'drafts', 'update', draftId];
|
|
368
461
|
appendDraftFlags(args, flags);
|
|
369
|
-
return
|
|
462
|
+
return writeDraft(args, account, returnFull, draftId);
|
|
370
463
|
});
|
|
371
464
|
|
|
372
465
|
server.registerTool('gog_gmail_drafts_delete', {
|
|
373
|
-
description: '
|
|
466
|
+
description: 'Permanently delete a Gmail draft (not reversible — drafts do not go to Trash). Requires force:true to delete non-interactively.',
|
|
374
467
|
annotations: { destructiveHint: true },
|
|
375
468
|
inputSchema: {
|
|
376
469
|
draftId: z.string().describe('Draft ID'),
|
|
470
|
+
force: z.boolean().optional().describe('Required to delete in this non-interactive context — without it the delete is refused as a safety guard.'),
|
|
377
471
|
account: accountParam,
|
|
378
472
|
},
|
|
379
|
-
}, async ({ draftId, account }) => {
|
|
380
|
-
|
|
473
|
+
}, async ({ draftId, account, force }) => {
|
|
474
|
+
const args = ['gmail', 'drafts', 'delete', draftId];
|
|
475
|
+
if (force) args.push('--force');
|
|
476
|
+
return runOrDiagnose(args, { account });
|
|
381
477
|
});
|
|
382
478
|
|
|
383
479
|
server.registerTool('gog_gmail_drafts_send', {
|
|
@@ -223,6 +223,72 @@ describe('gog_gmail_thread_get', () => {
|
|
|
223
223
|
{ account: undefined },
|
|
224
224
|
);
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
const THREAD = JSON.stringify({
|
|
228
|
+
downloaded: false,
|
|
229
|
+
thread: {
|
|
230
|
+
id: 't1',
|
|
231
|
+
messages: [
|
|
232
|
+
{ id: 'm1', threadId: 't1', internalDate: '1', labelIds: ['INBOX'], snippet: 'first', payload: { headers: [{ name: 'From', value: 'a@x.com' }, { name: 'Subject', value: 'Hi' }, { name: 'X-Spam', value: 'no' }, { value: 'orphan-no-name' }], body: { data: 'AAAA' } } },
|
|
233
|
+
{ id: 'm2', threadId: 't1', internalDate: '2', labelIds: ['INBOX'], snippet: 'second', payload: { headers: [{ name: 'From', value: 'b@x.com' }] } },
|
|
234
|
+
{ id: 'm3', threadId: 't1', internalDate: '3', labelIds: ['SENT'], snippet: 'third' },
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('does not transform the output when no paging params are given', async () => {
|
|
240
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
|
|
241
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1' });
|
|
242
|
+
expect(result.content[0].text).toBe(THREAD);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('latestN returns only the last N messages', async () => {
|
|
246
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
|
|
247
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 2 });
|
|
248
|
+
// latestN is wrapper-side; no CLI flag is added
|
|
249
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).toEqual(['gmail', 'thread', 'get', 't1']);
|
|
250
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
251
|
+
expect(parsed.thread.messages.map((m: { id: string }) => m.id)).toEqual(['m2', 'm3']);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('snippetsOnly returns per-message headers and snippet without bodies', async () => {
|
|
255
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
|
|
256
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', snippetsOnly: true });
|
|
257
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
258
|
+
expect(parsed.thread.messages).toHaveLength(3);
|
|
259
|
+
const m1 = parsed.thread.messages[0];
|
|
260
|
+
expect(m1.snippet).toBe('first');
|
|
261
|
+
expect(m1.headers).toEqual({ From: 'a@x.com', Subject: 'Hi' }); // X-Spam dropped
|
|
262
|
+
expect(m1.payload).toBeUndefined();
|
|
263
|
+
// a message with no payload yields empty headers without throwing
|
|
264
|
+
expect(parsed.thread.messages[2].headers).toEqual({});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('combines latestN and snippetsOnly', async () => {
|
|
268
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
|
|
269
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 1, snippetsOnly: true });
|
|
270
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
271
|
+
expect(parsed.thread.messages).toHaveLength(1);
|
|
272
|
+
expect(parsed.thread.messages[0].id).toBe('m3');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('returns the raw result when the payload is not JSON', async () => {
|
|
276
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('not json'));
|
|
277
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 2 });
|
|
278
|
+
expect(result.content[0].text).toBe('not json');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('returns the raw result when there is no messages array', async () => {
|
|
282
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{"thread":{}}'));
|
|
283
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', snippetsOnly: true });
|
|
284
|
+
expect(result.content[0].text).toBe('{"thread":{}}');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('returns the raw result when there is no thread object', async () => {
|
|
288
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{}'));
|
|
289
|
+
const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 1 });
|
|
290
|
+
expect(result.content[0].text).toBe('{}');
|
|
291
|
+
});
|
|
226
292
|
});
|
|
227
293
|
|
|
228
294
|
describe('gog_gmail_thread_modify', () => {
|
|
@@ -430,6 +496,53 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
430
496
|
{ account: undefined },
|
|
431
497
|
);
|
|
432
498
|
});
|
|
499
|
+
|
|
500
|
+
it('skips recipient flags when omitRecipients is true, even if to/cc/bcc are supplied', async () => {
|
|
501
|
+
await handlers.get('gog_gmail_drafts_create')!({
|
|
502
|
+
to: 'a@b.com', cc: 'cc@x.com', bcc: 'bcc@x.com',
|
|
503
|
+
subject: 'Hi', body: 'Hello', omitRecipients: true,
|
|
504
|
+
});
|
|
505
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
506
|
+
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello'],
|
|
507
|
+
{ account: undefined },
|
|
508
|
+
);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('returnFull re-fetches and returns the full stored draft', async () => {
|
|
512
|
+
vi.mocked(lib.runOrDiagnose)
|
|
513
|
+
.mockResolvedValueOnce(toText('{"draftId":"d9","message":{"id":"m9"}}'))
|
|
514
|
+
.mockResolvedValueOnce(toText('{"id":"d9","message":{"subject":"Hi","body":"Hello"}}'));
|
|
515
|
+
const result = await handlers.get('gog_gmail_drafts_create')!({
|
|
516
|
+
subject: 'Hi', body: 'Hello', returnFull: true,
|
|
517
|
+
});
|
|
518
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(1,
|
|
519
|
+
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello'], { account: undefined });
|
|
520
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
521
|
+
['gmail', 'drafts', 'get', 'd9'], { account: undefined });
|
|
522
|
+
expect(result.content[0].text).toContain('"subject":"Hi"');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('returnFull does not push --return-full to the CLI', async () => {
|
|
526
|
+
vi.mocked(lib.runOrDiagnose)
|
|
527
|
+
.mockResolvedValueOnce(toText('{"draftId":"d9"}'))
|
|
528
|
+
.mockResolvedValueOnce(toText('{}'));
|
|
529
|
+
await handlers.get('gog_gmail_drafts_create')!({ subject: 'Hi', body: 'Hello', returnFull: true });
|
|
530
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).not.toContain('--return-full');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('returnFull returns the write result when output is not parseable JSON', async () => {
|
|
534
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('not json'));
|
|
535
|
+
const result = await handlers.get('gog_gmail_drafts_create')!({ subject: 'Hi', body: 'Hello', returnFull: true });
|
|
536
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
537
|
+
expect(result.content[0].text).toBe('not json');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('returnFull returns the write result when no draftId is present', async () => {
|
|
541
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{"message":{"id":"m9"}}'));
|
|
542
|
+
const result = await handlers.get('gog_gmail_drafts_create')!({ subject: 'Hi', body: 'Hello', returnFull: true });
|
|
543
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
544
|
+
expect(result.content[0].text).toBe('{"message":{"id":"m9"}}');
|
|
545
|
+
});
|
|
433
546
|
});
|
|
434
547
|
|
|
435
548
|
describe('gog_gmail_drafts_update', () => {
|
|
@@ -457,6 +570,38 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
457
570
|
{ account: undefined },
|
|
458
571
|
);
|
|
459
572
|
});
|
|
573
|
+
|
|
574
|
+
it('skips recipient flags when omitRecipients is true', async () => {
|
|
575
|
+
await handlers.get('gog_gmail_drafts_update')!({
|
|
576
|
+
draftId: 'd1', to: 'a@b.com', subject: 'S', body: 'B', omitRecipients: true,
|
|
577
|
+
});
|
|
578
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
579
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B'],
|
|
580
|
+
{ account: undefined },
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('returnFull re-fetches the draft by its known id', async () => {
|
|
585
|
+
vi.mocked(lib.runOrDiagnose)
|
|
586
|
+
.mockResolvedValueOnce(toText('{"draftId":"d1"}'))
|
|
587
|
+
.mockResolvedValueOnce(toText('{"id":"d1","message":{"subject":"S"}}'));
|
|
588
|
+
const result = await handlers.get('gog_gmail_drafts_update')!({
|
|
589
|
+
draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
|
|
590
|
+
});
|
|
591
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
592
|
+
['gmail', 'drafts', 'get', 'd1'], { account: undefined });
|
|
593
|
+
expect(result.content[0].text).toContain('"subject":"S"');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('returnFull surfaces a failed update instead of re-fetching a stale draft', async () => {
|
|
597
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('Error: update failed'));
|
|
598
|
+
const result = await handlers.get('gog_gmail_drafts_update')!({
|
|
599
|
+
draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
|
|
600
|
+
});
|
|
601
|
+
// write failed (non-JSON) → no re-fetch; the error is surfaced
|
|
602
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
603
|
+
expect(result.content[0].text).toBe('Error: update failed');
|
|
604
|
+
});
|
|
460
605
|
});
|
|
461
606
|
|
|
462
607
|
describe('gog_gmail_drafts_delete', () => {
|
|
@@ -467,6 +612,22 @@ describe('gog_gmail_drafts_delete', () => {
|
|
|
467
612
|
{ account: undefined },
|
|
468
613
|
);
|
|
469
614
|
});
|
|
615
|
+
|
|
616
|
+
it('appends --force when force is true', async () => {
|
|
617
|
+
await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1', force: true });
|
|
618
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
619
|
+
['gmail', 'drafts', 'delete', 'd1', '--force'],
|
|
620
|
+
{ account: undefined },
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('omits --force when force is false', async () => {
|
|
625
|
+
await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1', force: false });
|
|
626
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
627
|
+
['gmail', 'drafts', 'delete', 'd1'],
|
|
628
|
+
{ account: undefined },
|
|
629
|
+
);
|
|
630
|
+
});
|
|
470
631
|
});
|
|
471
632
|
|
|
472
633
|
describe('gog_gmail_drafts_send', () => {
|