gogcli-mcp 2.26.0 → 2.27.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/SKILL.md +2 -2
- package/dist/index.js +71 -5
- package/dist/lib.js +74 -5
- package/manifest.json +9 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/lib.ts +6 -0
- package/src/tools/gmail.ts +136 -5
- package/src/tools/utils.ts +20 -0
- package/src/worker.ts +1 -1
- package/tests/server.test.ts +59 -0
- package/tests/tools/gmail.test.ts +320 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.27.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.27.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> [!WARNING]
|
|
4
4
|
> **AI-developed project.** This codebase was built and is actively maintained by [Claude Code](https://www.anthropic.com/claude). Review all code and tool permissions before use.
|
|
5
5
|
|
|
6
|
-
Base [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude access to Google Workspace via [gogcli](https://github.com/openclaw/gogcli). Includes
|
|
6
|
+
Base [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude access to Google Workspace via [gogcli](https://github.com/openclaw/gogcli). Includes 114 tools across 13 services: Sheets, Docs, Gmail, Calendar, Drive, Slides, Classroom, Chat, Apps Script, Tasks, Contacts, the generic Discovery API escape hatch, and Auth.
|
|
7
7
|
|
|
8
8
|
For extended Sheets or Docs support, see [gogcli-mcp-sheets](https://www.npmjs.com/package/gogcli-mcp-sheets) and [gogcli-mcp-docs](https://www.npmjs.com/package/gogcli-mcp-docs).
|
|
9
9
|
|
|
@@ -46,13 +46,13 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
46
46
|
claude mcp add gogcli-mcp -- gogcli-mcp
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
## Tools (
|
|
49
|
+
## Tools (114)
|
|
50
50
|
|
|
51
51
|
| Service | Tools | Includes |
|
|
52
52
|
|---------|-------|----------|
|
|
53
53
|
| **Sheets** | 8 | get, update, append, clear, metadata, create, find-replace, run |
|
|
54
54
|
| **Docs** | 7 | info, cat, create, write, find-replace, structure, run |
|
|
55
|
-
| **Gmail** |
|
|
55
|
+
| **Gmail** | 6 | search, get, send, reply, reply-all, run |
|
|
56
56
|
| **Calendar** | 7 | events, get, create, update, delete, respond, run |
|
|
57
57
|
| **Drive** | 11 | ls, search, get, mkdir, rename, move, delete, share, extract-text, read-bytes, run |
|
|
58
58
|
| **Slides** | 7 | export, info, create, copy, list-slides, read-slide, run |
|
package/SKILL.md
CHANGED
|
@@ -32,13 +32,13 @@ MCP server wrapping [gogcli](https://github.com/openclaw/gogcli) — provides Cl
|
|
|
32
32
|
|
|
33
33
|
`GOG_ACCOUNT` is optional — omit it to use gogcli's configured default account.
|
|
34
34
|
|
|
35
|
-
## Tools (
|
|
35
|
+
## Tools (114)
|
|
36
36
|
|
|
37
37
|
| Service | Tools |
|
|
38
38
|
|---------|-------|
|
|
39
39
|
| **Sheets** (8) | get, update, append, clear, metadata, create, find-replace, run |
|
|
40
40
|
| **Docs** (7) | info, cat, create, write, find-replace, structure, run |
|
|
41
|
-
| **Gmail** (
|
|
41
|
+
| **Gmail** (6) | search, get, send, reply, reply-all, run |
|
|
42
42
|
| **Calendar** (7) | events, get, create, update, delete, respond, run |
|
|
43
43
|
| **Drive** (11) | ls, search, get, mkdir, rename, move, delete, share, extract-text, read-bytes, run |
|
|
44
44
|
| **Slides** (7) | export, info, create, copy, list-slides, read-slide, run |
|
package/dist/index.js
CHANGED
|
@@ -31835,6 +31835,13 @@ function formatAuthHealth(raw, now) {
|
|
|
31835
31835
|
}
|
|
31836
31836
|
return accounts.map((a) => formatOneAccountHealth(a, now)).join("\n\n");
|
|
31837
31837
|
}
|
|
31838
|
+
function assertNotBoth(inlineParam, fileParam, inlineValue, fileValue) {
|
|
31839
|
+
if (inlineValue !== void 0 && fileValue !== void 0) {
|
|
31840
|
+
throw new Error(
|
|
31841
|
+
`${inlineParam} and ${fileParam} are mutually exclusive \u2014 gog accepts only one of them. Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), or ${fileParam} with a path that already exists on the gog server.`
|
|
31842
|
+
);
|
|
31843
|
+
}
|
|
31844
|
+
}
|
|
31838
31845
|
|
|
31839
31846
|
// src/tools/api.ts
|
|
31840
31847
|
function registerApiTools(server) {
|
|
@@ -33414,6 +33421,45 @@ function finish(base, itemsKey, merged, token) {
|
|
|
33414
33421
|
}
|
|
33415
33422
|
|
|
33416
33423
|
// src/tools/gmail.ts
|
|
33424
|
+
var replySchema = {
|
|
33425
|
+
messageId: external_exports.string().describe("Gmail message ID to reply to \u2014 the short hex `id` from gog_gmail_get / _search (or gog_gmail_messages_search, gogcli-mcp-gmail only). NOT the threadId, NOT the RFC822 `<\u2026@host>` Message-Id header."),
|
|
33426
|
+
body: external_exports.string().optional().describe("Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size \u2014 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."),
|
|
33427
|
+
bodyHtml: external_exports.string().optional().describe("Reply body (HTML; optional). Pass the HTML itself at any size \u2014 a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile."),
|
|
33428
|
+
bodyHtmlFile: external_exports.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 \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.`),
|
|
33429
|
+
to: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message."),
|
|
33430
|
+
cc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Cc (repeatable)"),
|
|
33431
|
+
bcc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Bcc (repeatable)"),
|
|
33432
|
+
remove: external_exports.array(external_exports.string()).optional().describe("Remove these recipients from all fields (repeatable) \u2014 e.g. to drop someone from a reply-all."),
|
|
33433
|
+
subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
33434
|
+
noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
|
|
33435
|
+
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.`),
|
|
33436
|
+
attachInline: attachInlineParam,
|
|
33437
|
+
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
33438
|
+
autoFromAddressedAlias: external_exports.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 \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
|
|
33439
|
+
signature: external_exports.boolean().optional().describe("Append the Gmail signature from the active send-as address"),
|
|
33440
|
+
signatureFrom: external_exports.string().optional().describe("Append the Gmail signature from this send-as email address"),
|
|
33441
|
+
signatureFile: external_exports.string().optional().describe("Append a local signature file (plain text or HTML), read on the gog server"),
|
|
33442
|
+
account: accountParam
|
|
33443
|
+
};
|
|
33444
|
+
function appendReplyFlags(args, f) {
|
|
33445
|
+
assertNotBoth("bodyHtml", "bodyHtmlFile", f.bodyHtml, f.bodyHtmlFile);
|
|
33446
|
+
if (f.body) args.push(payloadArg("body", "body-file", f.body));
|
|
33447
|
+
if (f.bodyHtml) args.push(payloadArg("body-html", "body-html-file", f.bodyHtml, "html"));
|
|
33448
|
+
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
33449
|
+
if (f.to) for (const r of f.to) args.push(`--to=${r}`);
|
|
33450
|
+
if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
|
|
33451
|
+
if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
|
|
33452
|
+
if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
|
|
33453
|
+
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
33454
|
+
if (f.noQuote) args.push("--no-quote");
|
|
33455
|
+
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
33456
|
+
args.push(...inlineAttachmentArgs("attach", f.attachInline, args));
|
|
33457
|
+
if (f.from) args.push(`--from=${f.from}`);
|
|
33458
|
+
if (f.signature) args.push("--signature");
|
|
33459
|
+
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
33460
|
+
if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
|
|
33461
|
+
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
33462
|
+
}
|
|
33417
33463
|
function registerGmailTools(server) {
|
|
33418
33464
|
server.registerTool("gog_gmail_search", {
|
|
33419
33465
|
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). Results are ALWAYS newest-first by Gmail\'s internalDate \u2014 the wrapper sorts them, so the first result is the most recent match and a recent message can never be buried below older ones. IMPORTANT \u2014 a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist, or that there is no such mail, on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query, and only then draw a conclusion. If you already know the thread, do not search for it at all \u2014 read it directly with gog_gmail_thread_get, which returns the whole thread and cannot be truncated or mis-ranked.',
|
|
@@ -33466,7 +33512,7 @@ function registerGmailTools(server) {
|
|
|
33466
33512
|
return runOrDiagnose(args, { account });
|
|
33467
33513
|
});
|
|
33468
33514
|
server.registerTool("gog_gmail_send", {
|
|
33469
|
-
description: 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded.',
|
|
33515
|
+
description: 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded. NOT the tool for answering a message: replyToMessageId only files this in the right thread \u2014 the subject, recipients and body are entirely yours, and the original is not quoted unless you set quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
|
|
33470
33516
|
annotations: { destructiveHint: true },
|
|
33471
33517
|
inputSchema: {
|
|
33472
33518
|
to: external_exports.string().describe("Recipient(s), comma-separated"),
|
|
@@ -33474,23 +33520,43 @@ function registerGmailTools(server) {
|
|
|
33474
33520
|
body: external_exports.string().describe("Email body (plain text). Any size \u2014 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."),
|
|
33475
33521
|
cc: external_exports.string().optional().describe("CC recipients, comma-separated"),
|
|
33476
33522
|
bcc: external_exports.string().optional().describe("BCC recipients, comma-separated"),
|
|
33477
|
-
replyToMessageId: external_exports.string().optional().describe(
|
|
33478
|
-
threadId: external_exports.string().optional().describe("Thread ID to
|
|
33523
|
+
replyToMessageId: external_exports.string().optional().describe('Message ID to thread this message against \u2014 sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
|
|
33524
|
+
threadId: external_exports.string().optional().describe("Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients."),
|
|
33525
|
+
quote: external_exports.boolean().optional().describe("Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it."),
|
|
33479
33526
|
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.`),
|
|
33480
33527
|
attachInline: attachInlineParam,
|
|
33481
33528
|
account: accountParam
|
|
33482
33529
|
}
|
|
33483
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
|
|
33530
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }) => {
|
|
33484
33531
|
const args = ["gmail", "send", `--to=${to}`, `--subject=${subject}`, payloadArg("body", "body-file", body)];
|
|
33485
33532
|
if (cc) args.push(`--cc=${cc}`);
|
|
33486
33533
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
33487
33534
|
if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
|
|
33488
33535
|
if (threadId) args.push(`--thread-id=${threadId}`);
|
|
33536
|
+
if (quote) args.push("--quote");
|
|
33489
33537
|
if (attach) for (const path of attach) args.push(`--attach=${path}`);
|
|
33490
33538
|
const inline = inlineAttachmentArgs("attach", attachInline, args);
|
|
33491
33539
|
args.push(...inline);
|
|
33492
33540
|
return runOrDiagnose(args, { account });
|
|
33493
33541
|
});
|
|
33542
|
+
server.registerTool("gog_gmail_reply", {
|
|
33543
|
+
description: 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below yours, which gog_gmail_send does not \u2014 a send with replyToMessageId lands in the right thread but reads as a brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft instead of sending it.',
|
|
33544
|
+
annotations: { destructiveHint: true },
|
|
33545
|
+
inputSchema: replySchema
|
|
33546
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
33547
|
+
const args = ["gmail", "reply", messageId];
|
|
33548
|
+
appendReplyFlags(args, flags);
|
|
33549
|
+
return runOrDiagnose(args, { account });
|
|
33550
|
+
});
|
|
33551
|
+
server.registerTool("gog_gmail_reply_all", {
|
|
33552
|
+
description: 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all. To stage it as a draft rather than send it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only).',
|
|
33553
|
+
annotations: { destructiveHint: true },
|
|
33554
|
+
inputSchema: replySchema
|
|
33555
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
33556
|
+
const args = ["gmail", "reply-all", messageId];
|
|
33557
|
+
appendReplyFlags(args, flags);
|
|
33558
|
+
return runOrDiagnose(args, { account });
|
|
33559
|
+
});
|
|
33494
33560
|
registerRunTool(server, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
|
|
33495
33561
|
}
|
|
33496
33562
|
|
|
@@ -33818,7 +33884,7 @@ function registerTasksTools(server) {
|
|
|
33818
33884
|
}
|
|
33819
33885
|
|
|
33820
33886
|
// src/server.ts
|
|
33821
|
-
var VERSION = true ? "2.
|
|
33887
|
+
var VERSION = true ? "2.27.0" : "0.0.0";
|
|
33822
33888
|
var BASE_TOOL_REGISTRARS = [
|
|
33823
33889
|
registerApiTools,
|
|
33824
33890
|
registerAppScriptTools,
|
package/dist/lib.js
CHANGED
|
@@ -23681,6 +23681,13 @@ function formatAuthHealth(raw, now) {
|
|
|
23681
23681
|
}
|
|
23682
23682
|
return accounts.map((a) => formatOneAccountHealth(a, now)).join("\n\n");
|
|
23683
23683
|
}
|
|
23684
|
+
function assertNotBoth(inlineParam, fileParam, inlineValue, fileValue) {
|
|
23685
|
+
if (inlineValue !== void 0 && fileValue !== void 0) {
|
|
23686
|
+
throw new Error(
|
|
23687
|
+
`${inlineParam} and ${fileParam} are mutually exclusive \u2014 gog accepts only one of them. Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), or ${fileParam} with a path that already exists on the gog server.`
|
|
23688
|
+
);
|
|
23689
|
+
}
|
|
23690
|
+
}
|
|
23684
23691
|
|
|
23685
23692
|
// src/tools/api.ts
|
|
23686
23693
|
function registerApiTools(server) {
|
|
@@ -25263,6 +25270,45 @@ function finish(base, itemsKey, merged, token) {
|
|
|
25263
25270
|
}
|
|
25264
25271
|
|
|
25265
25272
|
// src/tools/gmail.ts
|
|
25273
|
+
var replySchema = {
|
|
25274
|
+
messageId: external_exports.string().describe("Gmail message ID to reply to \u2014 the short hex `id` from gog_gmail_get / _search (or gog_gmail_messages_search, gogcli-mcp-gmail only). NOT the threadId, NOT the RFC822 `<\u2026@host>` Message-Id header."),
|
|
25275
|
+
body: external_exports.string().optional().describe("Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size \u2014 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."),
|
|
25276
|
+
bodyHtml: external_exports.string().optional().describe("Reply body (HTML; optional). Pass the HTML itself at any size \u2014 a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile."),
|
|
25277
|
+
bodyHtmlFile: external_exports.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 \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.`),
|
|
25278
|
+
to: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message."),
|
|
25279
|
+
cc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Cc (repeatable)"),
|
|
25280
|
+
bcc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Bcc (repeatable)"),
|
|
25281
|
+
remove: external_exports.array(external_exports.string()).optional().describe("Remove these recipients from all fields (repeatable) \u2014 e.g. to drop someone from a reply-all."),
|
|
25282
|
+
subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
25283
|
+
noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
|
|
25284
|
+
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.`),
|
|
25285
|
+
attachInline: attachInlineParam,
|
|
25286
|
+
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
25287
|
+
autoFromAddressedAlias: external_exports.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 \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
|
|
25288
|
+
signature: external_exports.boolean().optional().describe("Append the Gmail signature from the active send-as address"),
|
|
25289
|
+
signatureFrom: external_exports.string().optional().describe("Append the Gmail signature from this send-as email address"),
|
|
25290
|
+
signatureFile: external_exports.string().optional().describe("Append a local signature file (plain text or HTML), read on the gog server"),
|
|
25291
|
+
account: accountParam
|
|
25292
|
+
};
|
|
25293
|
+
function appendReplyFlags(args, f) {
|
|
25294
|
+
assertNotBoth("bodyHtml", "bodyHtmlFile", f.bodyHtml, f.bodyHtmlFile);
|
|
25295
|
+
if (f.body) args.push(payloadArg("body", "body-file", f.body));
|
|
25296
|
+
if (f.bodyHtml) args.push(payloadArg("body-html", "body-html-file", f.bodyHtml, "html"));
|
|
25297
|
+
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
25298
|
+
if (f.to) for (const r of f.to) args.push(`--to=${r}`);
|
|
25299
|
+
if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
|
|
25300
|
+
if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
|
|
25301
|
+
if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
|
|
25302
|
+
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
25303
|
+
if (f.noQuote) args.push("--no-quote");
|
|
25304
|
+
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
25305
|
+
args.push(...inlineAttachmentArgs("attach", f.attachInline, args));
|
|
25306
|
+
if (f.from) args.push(`--from=${f.from}`);
|
|
25307
|
+
if (f.signature) args.push("--signature");
|
|
25308
|
+
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
25309
|
+
if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
|
|
25310
|
+
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
25311
|
+
}
|
|
25266
25312
|
function registerGmailTools(server) {
|
|
25267
25313
|
server.registerTool("gog_gmail_search", {
|
|
25268
25314
|
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). Results are ALWAYS newest-first by Gmail\'s internalDate \u2014 the wrapper sorts them, so the first result is the most recent match and a recent message can never be buried below older ones. IMPORTANT \u2014 a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist, or that there is no such mail, on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query, and only then draw a conclusion. If you already know the thread, do not search for it at all \u2014 read it directly with gog_gmail_thread_get, which returns the whole thread and cannot be truncated or mis-ranked.',
|
|
@@ -25315,7 +25361,7 @@ function registerGmailTools(server) {
|
|
|
25315
25361
|
return runOrDiagnose(args, { account });
|
|
25316
25362
|
});
|
|
25317
25363
|
server.registerTool("gog_gmail_send", {
|
|
25318
|
-
description: 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded.',
|
|
25364
|
+
description: 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded. NOT the tool for answering a message: replyToMessageId only files this in the right thread \u2014 the subject, recipients and body are entirely yours, and the original is not quoted unless you set quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
|
|
25319
25365
|
annotations: { destructiveHint: true },
|
|
25320
25366
|
inputSchema: {
|
|
25321
25367
|
to: external_exports.string().describe("Recipient(s), comma-separated"),
|
|
@@ -25323,23 +25369,43 @@ function registerGmailTools(server) {
|
|
|
25323
25369
|
body: external_exports.string().describe("Email body (plain text). Any size \u2014 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."),
|
|
25324
25370
|
cc: external_exports.string().optional().describe("CC recipients, comma-separated"),
|
|
25325
25371
|
bcc: external_exports.string().optional().describe("BCC recipients, comma-separated"),
|
|
25326
|
-
replyToMessageId: external_exports.string().optional().describe(
|
|
25327
|
-
threadId: external_exports.string().optional().describe("Thread ID to
|
|
25372
|
+
replyToMessageId: external_exports.string().optional().describe('Message ID to thread this message against \u2014 sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
|
|
25373
|
+
threadId: external_exports.string().optional().describe("Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients."),
|
|
25374
|
+
quote: external_exports.boolean().optional().describe("Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it."),
|
|
25328
25375
|
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.`),
|
|
25329
25376
|
attachInline: attachInlineParam,
|
|
25330
25377
|
account: accountParam
|
|
25331
25378
|
}
|
|
25332
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
|
|
25379
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }) => {
|
|
25333
25380
|
const args = ["gmail", "send", `--to=${to}`, `--subject=${subject}`, payloadArg("body", "body-file", body)];
|
|
25334
25381
|
if (cc) args.push(`--cc=${cc}`);
|
|
25335
25382
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
25336
25383
|
if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
|
|
25337
25384
|
if (threadId) args.push(`--thread-id=${threadId}`);
|
|
25385
|
+
if (quote) args.push("--quote");
|
|
25338
25386
|
if (attach) for (const path of attach) args.push(`--attach=${path}`);
|
|
25339
25387
|
const inline = inlineAttachmentArgs("attach", attachInline, args);
|
|
25340
25388
|
args.push(...inline);
|
|
25341
25389
|
return runOrDiagnose(args, { account });
|
|
25342
25390
|
});
|
|
25391
|
+
server.registerTool("gog_gmail_reply", {
|
|
25392
|
+
description: 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below yours, which gog_gmail_send does not \u2014 a send with replyToMessageId lands in the right thread but reads as a brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft instead of sending it.',
|
|
25393
|
+
annotations: { destructiveHint: true },
|
|
25394
|
+
inputSchema: replySchema
|
|
25395
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
25396
|
+
const args = ["gmail", "reply", messageId];
|
|
25397
|
+
appendReplyFlags(args, flags);
|
|
25398
|
+
return runOrDiagnose(args, { account });
|
|
25399
|
+
});
|
|
25400
|
+
server.registerTool("gog_gmail_reply_all", {
|
|
25401
|
+
description: 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all. To stage it as a draft rather than send it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only).',
|
|
25402
|
+
annotations: { destructiveHint: true },
|
|
25403
|
+
inputSchema: replySchema
|
|
25404
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
25405
|
+
const args = ["gmail", "reply-all", messageId];
|
|
25406
|
+
appendReplyFlags(args, flags);
|
|
25407
|
+
return runOrDiagnose(args, { account });
|
|
25408
|
+
});
|
|
25343
25409
|
registerRunTool(server, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
|
|
25344
25410
|
}
|
|
25345
25411
|
|
|
@@ -25667,7 +25733,7 @@ function registerTasksTools(server) {
|
|
|
25667
25733
|
}
|
|
25668
25734
|
|
|
25669
25735
|
// src/server.ts
|
|
25670
|
-
var VERSION = true ? "2.
|
|
25736
|
+
var VERSION = true ? "2.27.0" : "0.0.0";
|
|
25671
25737
|
var BASE_TOOL_REGISTRARS = [
|
|
25672
25738
|
registerApiTools,
|
|
25673
25739
|
registerAppScriptTools,
|
|
@@ -26178,6 +26244,8 @@ export {
|
|
|
26178
26244
|
VERSION,
|
|
26179
26245
|
accountParam,
|
|
26180
26246
|
annotateTruncatedList,
|
|
26247
|
+
appendReplyFlags,
|
|
26248
|
+
assertNotBoth,
|
|
26181
26249
|
attachInlineParam,
|
|
26182
26250
|
authToolsFor,
|
|
26183
26251
|
diagnose,
|
|
@@ -26209,6 +26277,7 @@ export {
|
|
|
26209
26277
|
registerSheetsTools,
|
|
26210
26278
|
registerSlidesTools,
|
|
26211
26279
|
registerTasksTools,
|
|
26280
|
+
replySchema,
|
|
26212
26281
|
resolvePageToken,
|
|
26213
26282
|
run,
|
|
26214
26283
|
runBinary,
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.27.0",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -112,6 +112,14 @@
|
|
|
112
112
|
"name": "gog_gmail_send",
|
|
113
113
|
"description": "Send an email, with attachments from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)"
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
"name": "gog_gmail_reply",
|
|
117
|
+
"description": "Reply to a Gmail message (sender only), inheriting the \"Re:\" subject and quoting the original below your body"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"name": "gog_gmail_reply_all",
|
|
121
|
+
"description": "Reply to every participant of a Gmail message (sender plus all To/Cc), with the same inherited subject and quoted original"
|
|
122
|
+
},
|
|
115
123
|
{
|
|
116
124
|
"name": "gog_gmail_run",
|
|
117
125
|
"description": "Run any gog gmail subcommand (escape hatch)"
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.27.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.27.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/lib.ts
CHANGED
|
@@ -16,6 +16,11 @@ export {
|
|
|
16
16
|
registerSlidesTools,
|
|
17
17
|
registerTasksTools,
|
|
18
18
|
} from './server.js';
|
|
19
|
+
// The reply/reply-all schema and flag builder live in the base package so the
|
|
20
|
+
// gmail sub-package's draft-side twins reuse ONE definition — registering the
|
|
21
|
+
// same tool name from both registrar lists would be a duplicate-name error.
|
|
22
|
+
export { replySchema, appendReplyFlags } from './tools/gmail.js';
|
|
23
|
+
export type { ReplyFlags } from './tools/gmail.js';
|
|
19
24
|
export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
|
|
20
25
|
// Sub-package tools that read gog JSON through bare `run()` (rather than the
|
|
21
26
|
// `runOrDiagnose` seam) must still apply this, or their timestamps skip the
|
|
@@ -57,4 +62,5 @@ export {
|
|
|
57
62
|
pageAliasParam,
|
|
58
63
|
resolvePageToken,
|
|
59
64
|
registerRunTool,
|
|
65
|
+
assertNotBoth,
|
|
60
66
|
} from './tools/utils.js';
|
package/src/tools/gmail.ts
CHANGED
|
@@ -1,9 +1,86 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
|
|
3
|
+
import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken, assertNotBoth } from './utils.js';
|
|
4
4
|
import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
7
|
+
import type { InlineAttachmentInput } from '../attachments.js';
|
|
8
|
+
|
|
9
|
+
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
10
|
+
// only in the subcommand and default recipient set (reply → sender; reply-all
|
|
11
|
+
// → every participant). Recipient flags are repeatable on the CLI, so they are
|
|
12
|
+
// arrays here. --to/--cc/--bcc ADD or MOVE recipients onto the inherited reply
|
|
13
|
+
// set; --remove drops them. Body/HTML follow the same inline-or-file shape as
|
|
14
|
+
// the draft tools.
|
|
15
|
+
export const replySchema = {
|
|
16
|
+
messageId: z.string().describe('Gmail message ID to reply to — the short hex `id` from gog_gmail_get / _search (or gog_gmail_messages_search, gogcli-mcp-gmail only). NOT the threadId, NOT the RFC822 `<…@host>` Message-Id header.'),
|
|
17
|
+
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.'),
|
|
18
|
+
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.'),
|
|
19
|
+
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.'),
|
|
20
|
+
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.'),
|
|
21
|
+
cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
|
|
22
|
+
bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
|
|
23
|
+
remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
|
|
24
|
+
subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
25
|
+
noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
|
|
26
|
+
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
|
|
27
|
+
attachInline: attachInlineParam,
|
|
28
|
+
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
29
|
+
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.'),
|
|
30
|
+
signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
|
|
31
|
+
signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
|
|
32
|
+
signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
|
|
33
|
+
account: accountParam,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ReplyFlags = {
|
|
37
|
+
body?: string;
|
|
38
|
+
bodyHtml?: string;
|
|
39
|
+
bodyHtmlFile?: string;
|
|
40
|
+
to?: string[];
|
|
41
|
+
cc?: string[];
|
|
42
|
+
bcc?: string[];
|
|
43
|
+
remove?: string[];
|
|
44
|
+
subject?: string;
|
|
45
|
+
noQuote?: boolean;
|
|
46
|
+
attach?: string[];
|
|
47
|
+
attachInline?: InlineAttachmentInput[];
|
|
48
|
+
from?: string;
|
|
49
|
+
autoFromAddressedAlias?: boolean;
|
|
50
|
+
signature?: boolean;
|
|
51
|
+
signatureFrom?: string;
|
|
52
|
+
signatureFile?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
56
|
+
assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
|
|
57
|
+
if (f.body) args.push(payloadArg('body', 'body-file', f.body));
|
|
58
|
+
if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
|
|
59
|
+
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
60
|
+
if (f.to) for (const r of f.to) args.push(`--to=${r}`);
|
|
61
|
+
if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
|
|
62
|
+
if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
|
|
63
|
+
if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
|
|
64
|
+
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
65
|
+
if (f.noQuote) args.push('--no-quote');
|
|
66
|
+
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
67
|
+
// Same repeatable --attach flag, but the bytes travel with the call: the
|
|
68
|
+
// executor writes each one to a temp file beside gog and passes that path.
|
|
69
|
+
// This is the only attachment route that works when the caller and gog do not
|
|
70
|
+
// share a filesystem (hosted connector, GOG_RUNNER_URL backend). `args` is
|
|
71
|
+
// passed so the size check sees the body too, which shares the same budget
|
|
72
|
+
// once payloadArg has turned it into a file arg.
|
|
73
|
+
args.push(...inlineAttachmentArgs('attach', f.attachInline, args));
|
|
74
|
+
if (f.from) args.push(`--from=${f.from}`);
|
|
75
|
+
if (f.signature) args.push('--signature');
|
|
76
|
+
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
77
|
+
if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
|
|
78
|
+
// PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
|
|
79
|
+
// silently changes which address the mail goes out FROM, with nothing in the arg
|
|
80
|
+
// array to show for it — and the remote runner's backend env is not ours to set.
|
|
81
|
+
// An explicit flag is the only value authoritative on both transports.
|
|
82
|
+
args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
|
|
83
|
+
}
|
|
7
84
|
|
|
8
85
|
export function registerGmailTools(server: McpServer): void {
|
|
9
86
|
server.registerTool('gog_gmail_search', {
|
|
@@ -73,7 +150,10 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
73
150
|
+ 'the same machine gog runs on — on the hosted connector and any remote deployment there is no '
|
|
74
151
|
+ 'shared filesystem, so no path you can name resolves there and `attach` will fail with '
|
|
75
152
|
+ '"no such file or directory". When either is used, the JSON result echoes the attached filenames '
|
|
76
|
-
+ 'and byte sizes — check it to confirm the files were embedded.'
|
|
153
|
+
+ 'and byte sizes — check it to confirm the files were embedded. '
|
|
154
|
+
+ 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
|
|
155
|
+
+ 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
|
|
156
|
+
+ 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
|
|
77
157
|
annotations: { destructiveHint: true },
|
|
78
158
|
inputSchema: {
|
|
79
159
|
to: z.string().describe('Recipient(s), comma-separated'),
|
|
@@ -81,13 +161,14 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
81
161
|
body: z.string().describe('Email 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.'),
|
|
82
162
|
cc: z.string().optional().describe('CC recipients, comma-separated'),
|
|
83
163
|
bcc: z.string().optional().describe('BCC recipients, comma-separated'),
|
|
84
|
-
replyToMessageId: z.string().optional().describe('Message ID to reply
|
|
85
|
-
threadId: z.string().optional().describe('Thread ID to
|
|
164
|
+
replyToMessageId: z.string().optional().describe('Message ID to thread this message against — sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
|
|
165
|
+
threadId: z.string().optional().describe('Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients.'),
|
|
166
|
+
quote: z.boolean().optional().describe('Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it.'),
|
|
86
167
|
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
|
|
87
168
|
attachInline: attachInlineParam,
|
|
88
169
|
account: accountParam,
|
|
89
170
|
},
|
|
90
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
|
|
171
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }) => {
|
|
91
172
|
// A long body cannot ride in argv: the hosted runner caps a single arg and
|
|
92
173
|
// Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
|
|
93
174
|
// past the shared threshold; the executor materializes the temp file.
|
|
@@ -96,6 +177,10 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
96
177
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
97
178
|
if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
|
|
98
179
|
if (threadId) args.push(`--thread-id=${threadId}`);
|
|
180
|
+
// gog's --quote on `gmail send` is opt-in (a plain bool defaulting false),
|
|
181
|
+
// the mirror image of `gmail reply`, where quoting is the default and
|
|
182
|
+
// --no-quote opts out. Nothing here can be inferred from the reply target.
|
|
183
|
+
if (quote) args.push('--quote');
|
|
99
184
|
if (attach) for (const path of attach) args.push(`--attach=${path}`);
|
|
100
185
|
// Same repeatable --attach flag; the executor materializes each payload to a
|
|
101
186
|
// temp file beside gog and substitutes its path. `args` is passed so the
|
|
@@ -106,5 +191,51 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
106
191
|
return runOrDiagnose(args, { account });
|
|
107
192
|
});
|
|
108
193
|
|
|
194
|
+
// ==========================================================================
|
|
195
|
+
// REPLY / REPLY-ALL
|
|
196
|
+
//
|
|
197
|
+
// These live here, in the base package, because gog_gmail_send +
|
|
198
|
+
// replyToMessageId is NOT a reply. It sets In-Reply-To/References — so Gmail
|
|
199
|
+
// files it in the right thread — and stops there: no quoted original, no
|
|
200
|
+
// inherited "Re:" subject, no inherited recipients. To anyone reading the
|
|
201
|
+
// body it arrives as a brand-new message.
|
|
202
|
+
//
|
|
203
|
+
// The asymmetry is gog's: `gmail reply` quotes BY DEFAULT (opt out with
|
|
204
|
+
// --no-quote), while `gmail send` quotes only on an explicit --quote
|
|
205
|
+
// (internal/cmd/gmail_send.go, a plain bool defaulting false). The gmail
|
|
206
|
+
// sub-package reuses replySchema/appendReplyFlags for its draft-side twins
|
|
207
|
+
// rather than declaring a second copy — registering these tools twice in the
|
|
208
|
+
// one server would be a duplicate-name error.
|
|
209
|
+
// ==========================================================================
|
|
210
|
+
server.registerTool('gog_gmail_reply', {
|
|
211
|
+
description:
|
|
212
|
+
'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
|
|
213
|
+
+ 'answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below '
|
|
214
|
+
+ 'yours, which gog_gmail_send does not — a send with replyToMessageId lands in the right thread but reads as a '
|
|
215
|
+
+ 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
|
|
216
|
+
+ 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
|
|
217
|
+
+ 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
|
|
218
|
+
+ 'instead of sending it.',
|
|
219
|
+
annotations: { destructiveHint: true },
|
|
220
|
+
inputSchema: replySchema,
|
|
221
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
222
|
+
const args: GogArg[] = ['gmail', 'reply', messageId];
|
|
223
|
+
appendReplyFlags(args, flags);
|
|
224
|
+
return runOrDiagnose(args, { account });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
server.registerTool('gog_gmail_reply_all', {
|
|
228
|
+
description:
|
|
229
|
+
'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
|
|
230
|
+
+ 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
|
|
231
|
+
+ 'reply-all. To stage it as a draft rather than send it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only).',
|
|
232
|
+
annotations: { destructiveHint: true },
|
|
233
|
+
inputSchema: replySchema,
|
|
234
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
235
|
+
const args: GogArg[] = ['gmail', 'reply-all', messageId];
|
|
236
|
+
appendReplyFlags(args, flags);
|
|
237
|
+
return runOrDiagnose(args, { account });
|
|
238
|
+
});
|
|
239
|
+
|
|
109
240
|
registerRunTool(server, { service: 'gmail', examples: '"archive", "mark-read", "labels"' });
|
|
110
241
|
}
|
package/src/tools/utils.ts
CHANGED
|
@@ -441,3 +441,23 @@ export function formatAuthHealth(raw: string, now: number): string {
|
|
|
441
441
|
}
|
|
442
442
|
return accounts.map((a) => formatOneAccountHealth(a, now)).join('\n\n');
|
|
443
443
|
}
|
|
444
|
+
|
|
445
|
+
// gog rejects an inline flag together with its --*-file twin — `gmail drafts
|
|
446
|
+
// create` errors with "use only one of --body-html or --body-html-file", and
|
|
447
|
+
// `gmail forward` does the same for --note (misreporting it as --body). Catch
|
|
448
|
+
// the conflict here so the caller gets a message naming the TOOL params it
|
|
449
|
+
// actually passed, instead of a gog error naming flags it never saw.
|
|
450
|
+
export function assertNotBoth(
|
|
451
|
+
inlineParam: string,
|
|
452
|
+
fileParam: string,
|
|
453
|
+
inlineValue: string | undefined,
|
|
454
|
+
fileValue: string | undefined,
|
|
455
|
+
): void {
|
|
456
|
+
if (inlineValue !== undefined && fileValue !== undefined) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`${inlineParam} and ${fileParam} are mutually exclusive — gog accepts only one of them. ` +
|
|
459
|
+
`Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), ` +
|
|
460
|
+
`or ${fileParam} with a path that already exists on the gog server.`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.27.0'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
package/tests/server.test.ts
CHANGED
|
@@ -43,3 +43,62 @@ describe('VERSION', () => {
|
|
|
43
43
|
expect(VERSION).toBe('0.0.0');
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
// The tool counts in README.md, SKILL.md and manifest.json are hand-maintained
|
|
48
|
+
// and have now drifted twice (0ec3470 "correct the stale tool counts", then
|
|
49
|
+
// again when reply/reply-all landed). They are the first thing a reader sees,
|
|
50
|
+
// so derive the truth from the registrars and fail the build on a mismatch
|
|
51
|
+
// rather than catching it in review a release later.
|
|
52
|
+
describe('published tool counts match the registrars', () => {
|
|
53
|
+
const readPkgFile = async (name: string) => {
|
|
54
|
+
const { readFile } = await import('node:fs/promises');
|
|
55
|
+
const { fileURLToPath } = await import('node:url');
|
|
56
|
+
return readFile(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const liveToolNames = async (): Promise<string[]> => {
|
|
60
|
+
const harness = await createTestHarness((server) => {
|
|
61
|
+
for (const register of BASE_TOOL_REGISTRARS) register(server, undefined);
|
|
62
|
+
});
|
|
63
|
+
const names = (await harness.listTools()).map((t) => t.name);
|
|
64
|
+
await harness.close();
|
|
65
|
+
return names;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it('manifest.json lists exactly the registered tools', async () => {
|
|
69
|
+
const names = await liveToolNames();
|
|
70
|
+
const manifest = JSON.parse(await readPkgFile('manifest.json')) as { tools: Array<{ name: string }> };
|
|
71
|
+
expect([...manifest.tools.map((t) => t.name)].sort()).toEqual([...names].sort());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each(['README.md', 'SKILL.md'])('%s states the real total', async (file) => {
|
|
75
|
+
const total = (await liveToolNames()).length;
|
|
76
|
+
const text = await readPkgFile(file);
|
|
77
|
+
const heading = /^## Tools \((\d+)\)$/m.exec(text);
|
|
78
|
+
expect(heading, `${file} has no "## Tools (N)" heading`).not.toBeNull();
|
|
79
|
+
expect(Number(heading![1])).toBe(total);
|
|
80
|
+
// README also states the count in its opening paragraph.
|
|
81
|
+
const prose = /Includes (\d+) tools across/.exec(text);
|
|
82
|
+
if (prose) expect(Number(prose[1])).toBe(total);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each(['README.md', 'SKILL.md'])('%s states the real per-service counts', async (file) => {
|
|
86
|
+
const names = await liveToolNames();
|
|
87
|
+
const text = await readPkgFile(file);
|
|
88
|
+
// README: `| **Gmail** | 6 | …` SKILL: `| **Gmail** (6) | …`
|
|
89
|
+
const rows = [...text.matchAll(/^\| \*\*(.+?)\*\*(?: \((\d+)\)| \| (\d+))? \|/gm)];
|
|
90
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
91
|
+
const service = (row: string) => row.toLowerCase().replace(/[^a-z]/g, '');
|
|
92
|
+
// Map a table label onto the gog_<service>_ prefix its tools carry.
|
|
93
|
+
const prefixFor: Record<string, string> = {
|
|
94
|
+
appsscript: 'appscript', discoveryapi: 'api',
|
|
95
|
+
};
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
const stated = Number(row[2] ?? row[3]);
|
|
98
|
+
if (!Number.isFinite(stated)) continue;
|
|
99
|
+
const key = service(row[1]);
|
|
100
|
+
const prefix = `gog_${prefixFor[key] ?? key}_`;
|
|
101
|
+
expect(names.filter((n) => n.startsWith(prefix)).length, `${file} row ${row[1]}`).toBe(stated);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -260,6 +260,25 @@ describe('gog_gmail_send', () => {
|
|
|
260
260
|
);
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
// gog's --quote is opt-in on `gmail send` and default-on for `gmail reply`;
|
|
264
|
+
// a threaded send without it arrives with the original nowhere in the body.
|
|
265
|
+
it('appends --quote only when quote is set', async () => {
|
|
266
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
267
|
+
const harness = await setupHandlers();
|
|
268
|
+
await harness.callTool('gog_gmail_send', {
|
|
269
|
+
to: 'bob@example.com', subject: 'Re: Hi', body: 'Sure',
|
|
270
|
+
replyToMessageId: 'msg1', quote: true,
|
|
271
|
+
});
|
|
272
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
273
|
+
[
|
|
274
|
+
'gmail', 'send',
|
|
275
|
+
'--to=bob@example.com', '--subject=Re: Hi', '--body=Sure',
|
|
276
|
+
'--reply-to-message-id=msg1', '--quote',
|
|
277
|
+
],
|
|
278
|
+
{ account: undefined },
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
263
282
|
it('appends one --attach flag per file path', async () => {
|
|
264
283
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
265
284
|
const harness = await setupHandlers();
|
|
@@ -423,3 +442,304 @@ describe('gog_gmail_run', () => {
|
|
|
423
442
|
expect(result.content[0].text).toBe('Error: Run failed');
|
|
424
443
|
});
|
|
425
444
|
});
|
|
445
|
+
|
|
446
|
+
// ============================================================================
|
|
447
|
+
// REPLY / REPLY-ALL. The base package used to expose gog_gmail_send as its only
|
|
448
|
+
// Gmail write, so replying meant send + replyToMessageId: that threads the
|
|
449
|
+
// message (In-Reply-To/References) but quotes nothing, inherits no recipients
|
|
450
|
+
// and no "Re:" subject — the reply lands looking like a brand-new message.
|
|
451
|
+
// gog's `gmail reply` quotes BY DEFAULT (opt out with --no-quote), which is why
|
|
452
|
+
// these are separate tools rather than flags on send.
|
|
453
|
+
// ============================================================================
|
|
454
|
+
describe.each([
|
|
455
|
+
['gog_gmail_reply', 'reply'],
|
|
456
|
+
['gog_gmail_reply_all', 'reply-all'],
|
|
457
|
+
])('%s', (tool, subcommand) => {
|
|
458
|
+
it('calls run with the message id and body, quoting by default', async () => {
|
|
459
|
+
vi.mocked(runner.run).mockResolvedValue('{"id":"msg9"}');
|
|
460
|
+
const harness = await setupHandlers();
|
|
461
|
+
await harness.callTool(tool, { messageId: 'msg1', body: 'Sounds good' });
|
|
462
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
463
|
+
['gmail', subcommand, 'msg1', '--body=Sounds good', '--auto-from-addressed-alias=false'],
|
|
464
|
+
{ account: undefined },
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('appends --no-quote only when noQuote is set', async () => {
|
|
469
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
470
|
+
const harness = await setupHandlers();
|
|
471
|
+
await harness.callTool(tool, { messageId: 'msg1', body: 'Ack', noQuote: true });
|
|
472
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
473
|
+
['gmail', subcommand, 'msg1', '--body=Ack', '--no-quote', '--auto-from-addressed-alias=false'],
|
|
474
|
+
{ account: undefined },
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('appends optional flags when provided', async () => {
|
|
479
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
480
|
+
const harness = await setupHandlers();
|
|
481
|
+
await harness.callTool(tool, {
|
|
482
|
+
messageId: 'msg1',
|
|
483
|
+
body: 'Adding Carol',
|
|
484
|
+
to: ['bob@example.com'],
|
|
485
|
+
cc: ['carol@example.com'],
|
|
486
|
+
bcc: ['dave@example.com'],
|
|
487
|
+
remove: ['eve@example.com'],
|
|
488
|
+
subject: 'Re: Custom',
|
|
489
|
+
from: 'me@example.com',
|
|
490
|
+
account: 'me@example.com',
|
|
491
|
+
});
|
|
492
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
493
|
+
[
|
|
494
|
+
'gmail', subcommand, 'msg1', '--body=Adding Carol',
|
|
495
|
+
'--to=bob@example.com', '--cc=carol@example.com', '--bcc=dave@example.com',
|
|
496
|
+
'--remove=eve@example.com', '--subject=Re: Custom', '--from=me@example.com',
|
|
497
|
+
'--auto-from-addressed-alias=false',
|
|
498
|
+
],
|
|
499
|
+
{ account: 'me@example.com' },
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('pins --auto-from-addressed-alias on when requested', async () => {
|
|
504
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
505
|
+
const harness = await setupHandlers();
|
|
506
|
+
await harness.callTool(tool, { messageId: 'msg1', body: 'Hi', autoFromAddressedAlias: true });
|
|
507
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
508
|
+
['gmail', subcommand, 'msg1', '--body=Hi', '--auto-from-addressed-alias'],
|
|
509
|
+
{ account: undefined },
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('appends one --attach flag per file path', async () => {
|
|
514
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
515
|
+
const harness = await setupHandlers();
|
|
516
|
+
await harness.callTool(tool, {
|
|
517
|
+
messageId: 'msg1',
|
|
518
|
+
body: 'See attached',
|
|
519
|
+
attach: ['/tmp/shot.png', '/tmp/notes.pdf'],
|
|
520
|
+
});
|
|
521
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
522
|
+
[
|
|
523
|
+
'gmail', subcommand, 'msg1', '--body=See attached',
|
|
524
|
+
'--attach=/tmp/shot.png', '--attach=/tmp/notes.pdf',
|
|
525
|
+
'--auto-from-addressed-alias=false',
|
|
526
|
+
],
|
|
527
|
+
{ account: undefined },
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('turns attachInline bytes into a --attach file arg', async () => {
|
|
532
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
533
|
+
const harness = await setupHandlers();
|
|
534
|
+
const bytes = Buffer.from('hi').toString('base64');
|
|
535
|
+
await harness.callTool(tool, {
|
|
536
|
+
messageId: 'msg1',
|
|
537
|
+
body: 'Bytes',
|
|
538
|
+
attachInline: [{ filename: 'a.txt', contentBase64: bytes }],
|
|
539
|
+
});
|
|
540
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
541
|
+
[
|
|
542
|
+
'gmail', subcommand, 'msg1', '--body=Bytes',
|
|
543
|
+
{ kind: 'file', flag: 'attach', contents: bytes, encoding: 'base64', filename: 'a.txt' },
|
|
544
|
+
'--auto-from-addressed-alias=false',
|
|
545
|
+
],
|
|
546
|
+
{ account: undefined },
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('routes an oversize body to --body-file', async () => {
|
|
551
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
552
|
+
const harness = await setupHandlers();
|
|
553
|
+
const big = 'x'.repeat(PAYLOAD_INLINE_MAX + 1);
|
|
554
|
+
await harness.callTool(tool, { messageId: 'msg1', body: big });
|
|
555
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
556
|
+
[
|
|
557
|
+
'gmail', subcommand, 'msg1',
|
|
558
|
+
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
559
|
+
'--auto-from-addressed-alias=false',
|
|
560
|
+
],
|
|
561
|
+
{ account: undefined },
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('returns error text on failure', async () => {
|
|
566
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('Reply failed'));
|
|
567
|
+
const harness = await setupHandlers();
|
|
568
|
+
const result = await harness.callTool(tool, { messageId: 'msg1', body: 'x' });
|
|
569
|
+
expect(result.content[0].text).toBe('Error: Reply failed');
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Moved here with the tools themselves: reply/reply-all used to live in the
|
|
574
|
+
// gmail sub-package, which now imports replySchema/appendReplyFlags from this
|
|
575
|
+
// package instead of declaring a second copy.
|
|
576
|
+
describe('gog_gmail_reply — full flag set', () => {
|
|
577
|
+
it('calls runOrDiagnose with messageId and --body', async () => {
|
|
578
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
579
|
+
const harness = await setupHandlers();
|
|
580
|
+
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Thanks' });
|
|
581
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
582
|
+
['gmail', 'reply', 'm1', '--body=Thanks', '--auto-from-addressed-alias=false'],
|
|
583
|
+
{ account: undefined },
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('passes all reply flags including repeatable recipients', async () => {
|
|
588
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
589
|
+
const harness = await setupHandlers();
|
|
590
|
+
await harness.callTool('gog_gmail_reply', {
|
|
591
|
+
messageId: 'm1',
|
|
592
|
+
body: 'Hi',
|
|
593
|
+
bodyHtml: '<p>Hi</p>',
|
|
594
|
+
to: ['a@b.com', 'c@d.com'],
|
|
595
|
+
cc: ['cc@x.com'],
|
|
596
|
+
bcc: ['bcc@x.com'],
|
|
597
|
+
remove: ['old@x.com'],
|
|
598
|
+
subject: 'New subject',
|
|
599
|
+
noQuote: true,
|
|
600
|
+
attach: ['/tmp/a.pdf', '/tmp/b.pdf'],
|
|
601
|
+
from: 'me@x.com',
|
|
602
|
+
signature: true,
|
|
603
|
+
signatureFrom: 'alias@x.com',
|
|
604
|
+
signatureFile: '/tmp/sig.txt',
|
|
605
|
+
account: 'me@gmail.com',
|
|
606
|
+
});
|
|
607
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
608
|
+
[
|
|
609
|
+
'gmail', 'reply', 'm1',
|
|
610
|
+
'--body=Hi',
|
|
611
|
+
'--body-html=<p>Hi</p>',
|
|
612
|
+
'--to=a@b.com',
|
|
613
|
+
'--to=c@d.com',
|
|
614
|
+
'--cc=cc@x.com',
|
|
615
|
+
'--bcc=bcc@x.com',
|
|
616
|
+
'--remove=old@x.com',
|
|
617
|
+
'--subject=New subject',
|
|
618
|
+
'--no-quote',
|
|
619
|
+
'--attach=/tmp/a.pdf',
|
|
620
|
+
'--attach=/tmp/b.pdf',
|
|
621
|
+
'--from=me@x.com',
|
|
622
|
+
'--signature',
|
|
623
|
+
'--signature-from=alias@x.com',
|
|
624
|
+
'--signature-file=/tmp/sig.txt', '--auto-from-addressed-alias=false'
|
|
625
|
+
],
|
|
626
|
+
{ account: 'me@gmail.com' },
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('omits --no-quote and --signature when false', async () => {
|
|
631
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
632
|
+
const harness = await setupHandlers();
|
|
633
|
+
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', noQuote: false, signature: false });
|
|
634
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
635
|
+
['gmail', 'reply', 'm1', '--body=Hi', '--auto-from-addressed-alias=false'],
|
|
636
|
+
{ account: undefined },
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe('gog_gmail_reply_all — full flag set', () => {
|
|
642
|
+
it('uses the reply-all subcommand', async () => {
|
|
643
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
644
|
+
const harness = await setupHandlers();
|
|
645
|
+
await harness.callTool('gog_gmail_reply_all', { messageId: 'm1', body: 'Thanks all' });
|
|
646
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
647
|
+
['gmail', 'reply-all', 'm1', '--body=Thanks all', '--auto-from-addressed-alias=false'],
|
|
648
|
+
{ account: undefined },
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('passes repeatable recipient and signature flags', async () => {
|
|
653
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
654
|
+
const harness = await setupHandlers();
|
|
655
|
+
await harness.callTool('gog_gmail_reply_all', {
|
|
656
|
+
messageId: 'm1',
|
|
657
|
+
bodyHtml: '<p>Hi</p>',
|
|
658
|
+
cc: ['x@y.com', 'z@y.com'],
|
|
659
|
+
remove: ['drop@y.com'],
|
|
660
|
+
signatureFile: '/tmp/sig.html',
|
|
661
|
+
});
|
|
662
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
663
|
+
[
|
|
664
|
+
'gmail', 'reply-all', 'm1',
|
|
665
|
+
'--body-html=<p>Hi</p>',
|
|
666
|
+
'--cc=x@y.com',
|
|
667
|
+
'--cc=z@y.com',
|
|
668
|
+
'--remove=drop@y.com',
|
|
669
|
+
'--signature-file=/tmp/sig.html', '--auto-from-addressed-alias=false'
|
|
670
|
+
],
|
|
671
|
+
{ account: undefined },
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
describe('gog_gmail_reply body-vs-file conflicts', () => {
|
|
676
|
+
it('rejects bodyHtml plus bodyHtmlFile before gog runs', async () => {
|
|
677
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
678
|
+
const harness = await setupHandlers();
|
|
679
|
+
const res = await harness.callTool('gog_gmail_reply', {
|
|
680
|
+
messageId: 'm1', bodyHtml: '<p>Hi</p>', bodyHtmlFile: '/tmp/b.html',
|
|
681
|
+
});
|
|
682
|
+
expect(res.isError).toBe(true);
|
|
683
|
+
expect(res.content[0].text).toContain('bodyHtml and bodyHtmlFile are mutually exclusive');
|
|
684
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('treats an empty-string bodyHtml as supplied, so it still conflicts', async () => {
|
|
688
|
+
// Guards the `!== undefined` check against a falsy-but-present value
|
|
689
|
+
// sliding through to gog, which rejects the pair regardless of content.
|
|
690
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
691
|
+
const harness = await setupHandlers();
|
|
692
|
+
const res = await harness.callTool('gog_gmail_reply', {
|
|
693
|
+
messageId: 'm1', body: 'B', bodyHtml: '', bodyHtmlFile: '/tmp/b.html',
|
|
694
|
+
});
|
|
695
|
+
expect(res.isError).toBe(true);
|
|
696
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('passes bodyHtmlFile alone through as --body-html-file', async () => {
|
|
700
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
701
|
+
const harness = await setupHandlers();
|
|
702
|
+
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', bodyHtmlFile: '/tmp/b.html' });
|
|
703
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
704
|
+
['gmail', 'reply', 'm1', '--body=Hi', '--body-html-file=/tmp/b.html', '--auto-from-addressed-alias=false'],
|
|
705
|
+
{ account: undefined },
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('routes a large body and bodyHtml to file args', async () => {
|
|
710
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
711
|
+
const harness = await setupHandlers();
|
|
712
|
+
const big = 'x'.repeat(PAYLOAD_INLINE_MAX + 1);
|
|
713
|
+
const bigHtml = `<p>${'y'.repeat(PAYLOAD_INLINE_MAX)}</p>`;
|
|
714
|
+
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: big, bodyHtml: bigHtml });
|
|
715
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
716
|
+
[
|
|
717
|
+
'gmail', 'reply', 'm1',
|
|
718
|
+
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
719
|
+
{ kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' },
|
|
720
|
+
'--auto-from-addressed-alias=false',
|
|
721
|
+
],
|
|
722
|
+
{ account: undefined },
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// gog reads "-" from stdin, but runner.ts spawns with default stdio and never
|
|
728
|
+
// writes to or closes the child's stdin, so a "-" here hangs until the 30s
|
|
729
|
+
// timeout. No file param may advertise it as usable.
|
|
730
|
+
describe('reply file params never advertise stdin as usable', () => {
|
|
731
|
+
it.each(['gog_gmail_reply', 'gog_gmail_reply_all'])('%s.bodyHtmlFile warns that stdin hangs', async (tool) => {
|
|
732
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
733
|
+
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
734
|
+
const configs = new Map<string, Record<string, { description?: string }>>();
|
|
735
|
+
vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
|
|
736
|
+
configs.set(name, (config as { inputSchema: Record<string, { description?: string }> }).inputSchema);
|
|
737
|
+
return undefined as never;
|
|
738
|
+
});
|
|
739
|
+
registerGmailTools(server);
|
|
740
|
+
const desc = configs.get(tool)?.bodyHtmlFile?.description ?? '';
|
|
741
|
+
expect(desc).not.toBe('');
|
|
742
|
+
expect(desc).not.toMatch(/(?:or|use)\s+"?-"?\s+(?:for|to read)/i);
|
|
743
|
+
expect(desc).toMatch(/stdin/i);
|
|
744
|
+
});
|
|
745
|
+
});
|