gogcli-mcp-gmail 2.23.1 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/SKILL.md +4 -1
- package/dist/index.js +266 -48
- package/manifest.json +16 -4
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +106 -16
- package/tests/tools/gmail-extra.test.ts +241 -2
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
|
-
Extended Gmail [MCP](https://modelcontextprotocol.io) server via [gogcli](https://github.com/openclaw/gogcli). Includes auth tools plus
|
|
6
|
+
Extended Gmail [MCP](https://modelcontextprotocol.io) server via [gogcli](https://github.com/openclaw/gogcli). Includes auth tools plus 49 additional dedicated Gmail tools for threads, labels, drafts, attachments, forwarding, autoreply, and bulk operations.
|
|
7
7
|
|
|
8
8
|
## Requirements
|
|
9
9
|
|
|
@@ -44,9 +44,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
44
44
|
claude mcp add gogcli-gmail -- gogcli-mcp-gmail
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
## Extra Gmail Tools (
|
|
47
|
+
## Extra Gmail Tools (49)
|
|
48
48
|
|
|
49
|
-
Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) —
|
|
49
|
+
Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) — 61 in all.
|
|
50
50
|
|
|
51
51
|
### Read
|
|
52
52
|
|
|
@@ -101,6 +101,9 @@ Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) — 58 in all.
|
|
|
101
101
|
| `gog_gmail_drafts_delete` | Delete a draft |
|
|
102
102
|
| `gog_gmail_drafts_send` | Send an existing draft (a 404 comes back diagnosed — `DRAFT_FORKED`, or `GOOGLE_404_NOT_THE_DRAFT` when the draft is still listed) |
|
|
103
103
|
| `gog_gmail_drafts_diff` | Diff two named drafts — divergent body lines (with untruncated `onlyInACount`/`onlyInBCount`), threading loss, and a conservative fork verdict (2 gog calls) |
|
|
104
|
+
| `gog_gmail_drafts_reply` | Save a reply as a draft — inherited recipients, subject and quote; never sends |
|
|
105
|
+
| `gog_gmail_drafts_reply_all` | Save a reply-all as a draft; never sends |
|
|
106
|
+
| `gog_gmail_drafts_forward` | Save a forward as a draft; recipients optional, so it can be staged without any |
|
|
104
107
|
|
|
105
108
|
#### When a draft you created stops resolving
|
|
106
109
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Use when the user asks to read, organize, draft, forward, autoreply
|
|
|
5
5
|
|
|
6
6
|
# gogcli-mcp-gmail
|
|
7
7
|
|
|
8
|
-
Extended Gmail MCP server via [gogcli](https://github.com/openclaw/gogcli) —
|
|
8
|
+
Extended Gmail MCP server via [gogcli](https://github.com/openclaw/gogcli) — 61 tools: 8 auth + 4 base Gmail + 49 extra dedicated Gmail tools.
|
|
9
9
|
|
|
10
10
|
- **Source:** [github.com/chrischall/gogcli-mcp](https://github.com/chrischall/gogcli-mcp)
|
|
11
11
|
|
|
@@ -80,6 +80,9 @@ Extended Gmail MCP server via [gogcli](https://github.com/openclaw/gogcli) — 5
|
|
|
80
80
|
| `gog_gmail_drafts_delete` | Delete a draft |
|
|
81
81
|
| `gog_gmail_drafts_send` | Send a draft (404 → `DRAFT_FORKED`, or `GOOGLE_404_NOT_THE_DRAFT` if the draft is still listed) |
|
|
82
82
|
| `gog_gmail_drafts_diff` | Diff two drafts (body divergence, threading loss, fork verdict) |
|
|
83
|
+
| `gog_gmail_drafts_reply` | Save a reply as a draft — inherited recipients, subject and quote; never sends |
|
|
84
|
+
| `gog_gmail_drafts_reply_all` | Save a reply-all as a draft; never sends |
|
|
85
|
+
| `gog_gmail_drafts_forward` | Save a forward as a draft; recipients optional, so it can be staged without any |
|
|
83
86
|
|
|
84
87
|
A draft edited in a mail client is replaced, not updated: the old id 404s. `drafts_update` / `drafts_send` answer that
|
|
85
88
|
404 with a `DRAFT_FORKED` report (what happened, the drafts that exist, what to do) instead of a bare `notFound`, at a
|
package/dist/index.js
CHANGED
|
@@ -31547,6 +31547,40 @@ function normalizeTimestamps(text, tz = displayTimeZone(), naiveTz = naiveSource
|
|
|
31547
31547
|
return JSON.stringify(parsed, null, detectIndent(text));
|
|
31548
31548
|
}
|
|
31549
31549
|
|
|
31550
|
+
// ../gogcli-mcp/src/pagination.ts
|
|
31551
|
+
function detectIndent2(text) {
|
|
31552
|
+
const match = /\n(\s+)\S/.exec(text);
|
|
31553
|
+
return match ? match[1].replace(/\t/g, " ").length : 0;
|
|
31554
|
+
}
|
|
31555
|
+
function stripConsumedPageToken(text) {
|
|
31556
|
+
const trimmed = text.trim();
|
|
31557
|
+
if (trimmed === "" || !trimmed.startsWith("{")) return text;
|
|
31558
|
+
let parsed;
|
|
31559
|
+
try {
|
|
31560
|
+
parsed = JSON.parse(trimmed);
|
|
31561
|
+
} catch {
|
|
31562
|
+
return text;
|
|
31563
|
+
}
|
|
31564
|
+
const obj = parsed;
|
|
31565
|
+
if (obj.nextPageToken !== "") return text;
|
|
31566
|
+
delete obj.nextPageToken;
|
|
31567
|
+
return JSON.stringify(obj, null, detectIndent2(text));
|
|
31568
|
+
}
|
|
31569
|
+
function truncationWarning(returned, count) {
|
|
31570
|
+
const scope = count.total !== void 0 ? `returned ${returned} of ${count.total} matches` : count.atLeast !== void 0 ? `returned ${returned} of at least ${count.atLeast} matches` : `returned ${returned} matches and MORE EXIST beyond this page`;
|
|
31571
|
+
return `INCOMPLETE RESULT SET: ${scope}. Do not report an absence of results based on this response. Page with nextPageToken or narrow the query.`;
|
|
31572
|
+
}
|
|
31573
|
+
function annotateTruncation(out, returned, count) {
|
|
31574
|
+
out.truncated = true;
|
|
31575
|
+
out.returned = returned;
|
|
31576
|
+
if (count.total !== void 0) out.totalMatches = count.total;
|
|
31577
|
+
if (count.atLeast !== void 0) out.totalMatchesAtLeast = count.atLeast;
|
|
31578
|
+
out.warning = truncationWarning(returned, count);
|
|
31579
|
+
}
|
|
31580
|
+
function hasMorePages(parsed) {
|
|
31581
|
+
return typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "";
|
|
31582
|
+
}
|
|
31583
|
+
|
|
31550
31584
|
// ../gogcli-mcp/src/tools/utils.ts
|
|
31551
31585
|
var PAYLOAD_INLINE_MAX = 4096;
|
|
31552
31586
|
function payloadArg(inlineFlag, fileFlag, value, ext) {
|
|
@@ -31582,9 +31616,19 @@ var ids = {
|
|
|
31582
31616
|
// People API uses fully-qualified resource names ("people/c123") not bare IDs.
|
|
31583
31617
|
person: external_exports.string().describe("Person resource name (people/...) or email")
|
|
31584
31618
|
};
|
|
31619
|
+
var pageTokenParam = external_exports.string().optional().describe(
|
|
31620
|
+
"Cursor for the NEXT page. Pass back the nextPageToken from a previous response verbatim, keeping the query and max identical: call once, then call again with pageToken=<that value>. A response with NO nextPageToken is the last page."
|
|
31621
|
+
);
|
|
31622
|
+
var pageAliasParam = external_exports.string().optional().describe(
|
|
31623
|
+
"Deprecated alias for pageToken, accepted so existing callers keep working. Use pageToken \u2014 it matches the nextPageToken field in the response."
|
|
31624
|
+
);
|
|
31625
|
+
function resolvePageToken(p) {
|
|
31626
|
+
return p.pageToken ?? p.page;
|
|
31627
|
+
}
|
|
31585
31628
|
var paginationParams = {
|
|
31586
31629
|
max: external_exports.number().int().optional().describe("Max results"),
|
|
31587
|
-
|
|
31630
|
+
pageToken: pageTokenParam,
|
|
31631
|
+
page: pageAliasParam,
|
|
31588
31632
|
all: external_exports.boolean().optional().describe("Fetch all pages")
|
|
31589
31633
|
};
|
|
31590
31634
|
function registerRunTool(server, options) {
|
|
@@ -31659,7 +31703,7 @@ ${accounts || "(none)"}${hint}`);
|
|
|
31659
31703
|
async function runOrDiagnose(args, options) {
|
|
31660
31704
|
try {
|
|
31661
31705
|
const raw = await run(args, options);
|
|
31662
|
-
return rawTextResult(options.lossless ? raw : normalizeTimestamps(raw));
|
|
31706
|
+
return rawTextResult(options.lossless ? raw : stripConsumedPageToken(normalizeTimestamps(raw)));
|
|
31663
31707
|
} catch (err) {
|
|
31664
31708
|
return diagnose(err);
|
|
31665
31709
|
}
|
|
@@ -31709,6 +31753,7 @@ function formatAuthHealth(raw, now) {
|
|
|
31709
31753
|
// ../gogcli-mcp/src/tools/auth.ts
|
|
31710
31754
|
function registerAuthToolsWith(server, defaultServices) {
|
|
31711
31755
|
const servicesDescribe = `Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "${defaultServices}". Prefer the narrowest set you need \u2014 requesting a service whose Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request with invalid_scope.`;
|
|
31756
|
+
const extraScopesDescribe = "Additional raw OAuth scope URIs to request, comma-separated, on top of the ones `services` implies. Use for scopes no service covers \u2014 e.g. https://www.googleapis.com/auth/bigquery.readonly, required before gog_sheets_datasource_* can read BigQuery-backed Connected Sheets. Leave unset otherwise: an extra scope whose API is not enabled on the OAuth client project makes Google reject the WHOLE authorization with invalid_scope.";
|
|
31712
31757
|
server.registerTool("gog_auth_list", {
|
|
31713
31758
|
description: "List the Google accounts stored in gogcli, with their scopes. This reads local configuration only \u2014 it does not contact Google and does NOT tell you whether an account still works: a signed-out account whose refresh token expired or was revoked is listed here exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account can actually authenticate.",
|
|
31714
31759
|
annotations: { readOnlyHint: true },
|
|
@@ -31758,11 +31803,14 @@ function registerAuthToolsWith(server, defaultServices) {
|
|
|
31758
31803
|
annotations: { destructiveHint: true },
|
|
31759
31804
|
inputSchema: {
|
|
31760
31805
|
email: external_exports.string().describe("Google account email to authorize"),
|
|
31761
|
-
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe)
|
|
31806
|
+
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
31807
|
+
extraScopes: external_exports.string().optional().describe(extraScopesDescribe)
|
|
31762
31808
|
}
|
|
31763
|
-
}, async ({ email: email3, services = defaultServices }) => {
|
|
31809
|
+
}, async ({ email: email3, services = defaultServices, extraScopes }) => {
|
|
31764
31810
|
try {
|
|
31765
|
-
|
|
31811
|
+
const args = ["auth", "add", email3, "--services", services];
|
|
31812
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`, "--force-consent");
|
|
31813
|
+
return rawTextResult(await run(args, {
|
|
31766
31814
|
interactive: true,
|
|
31767
31815
|
timeout: 3e5
|
|
31768
31816
|
}));
|
|
@@ -31774,14 +31822,14 @@ function registerAuthToolsWith(server, defaultServices) {
|
|
|
31774
31822
|
description: "Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any browser \u2014 no local server or terminal on the gogcli host is needed, so this works over the hosted connector where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, the browser is redirected to a localhost URL that fails to load \u2014 that is expected. They copy that full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to gog_auth_add_complete or the second step will not match this one.",
|
|
31775
31823
|
inputSchema: {
|
|
31776
31824
|
email: external_exports.string().describe("Google account email to authorize"),
|
|
31777
|
-
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe)
|
|
31825
|
+
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
31826
|
+
extraScopes: external_exports.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`)
|
|
31778
31827
|
}
|
|
31779
|
-
}, async ({ email: email3, services = defaultServices }) => {
|
|
31828
|
+
}, async ({ email: email3, services = defaultServices, extraScopes }) => {
|
|
31780
31829
|
try {
|
|
31781
|
-
|
|
31782
|
-
|
|
31783
|
-
|
|
31784
|
-
));
|
|
31830
|
+
const args = ["auth", "add", email3, "--remote", "--step", "1", "--services", services, "--force-consent"];
|
|
31831
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
31832
|
+
return rawTextResult(await run(args, { redactMode: "tokens" }));
|
|
31785
31833
|
} catch (err) {
|
|
31786
31834
|
return errorResult(errorText(err));
|
|
31787
31835
|
}
|
|
@@ -31796,25 +31844,28 @@ function registerAuthToolsWith(server, defaultServices) {
|
|
|
31796
31844
|
),
|
|
31797
31845
|
services: external_exports.string().optional().default(defaultServices).describe(
|
|
31798
31846
|
`Services authorized \u2014 MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`
|
|
31847
|
+
),
|
|
31848
|
+
extraScopes: external_exports.string().optional().describe(
|
|
31849
|
+
"Extra OAuth scope URIs \u2014 MUST match the value passed to gog_auth_add_url, for the same reason `services` must: the two steps have to describe the same grant."
|
|
31799
31850
|
)
|
|
31800
31851
|
}
|
|
31801
|
-
}, async ({ email: email3, redirectUrl, services = defaultServices }) => {
|
|
31852
|
+
}, async ({ email: email3, redirectUrl, services = defaultServices, extraScopes }) => {
|
|
31802
31853
|
try {
|
|
31803
|
-
|
|
31804
|
-
|
|
31805
|
-
|
|
31806
|
-
|
|
31807
|
-
|
|
31808
|
-
|
|
31809
|
-
|
|
31810
|
-
|
|
31811
|
-
|
|
31812
|
-
|
|
31813
|
-
|
|
31814
|
-
|
|
31815
|
-
|
|
31816
|
-
|
|
31817
|
-
));
|
|
31854
|
+
const args = [
|
|
31855
|
+
"auth",
|
|
31856
|
+
"add",
|
|
31857
|
+
email3,
|
|
31858
|
+
"--remote",
|
|
31859
|
+
"--step",
|
|
31860
|
+
"2",
|
|
31861
|
+
"--auth-url",
|
|
31862
|
+
redirectUrl,
|
|
31863
|
+
"--services",
|
|
31864
|
+
services,
|
|
31865
|
+
"--force-consent"
|
|
31866
|
+
];
|
|
31867
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
31868
|
+
return rawTextResult(await run(args));
|
|
31818
31869
|
} catch (err) {
|
|
31819
31870
|
return errorResult(errorText(err));
|
|
31820
31871
|
}
|
|
@@ -31830,34 +31881,148 @@ function authToolsFor(defaultServices) {
|
|
|
31830
31881
|
return (server) => registerAuthToolsWith(server, defaultServices);
|
|
31831
31882
|
}
|
|
31832
31883
|
|
|
31884
|
+
// ../gogcli-mcp/src/gmail-results.ts
|
|
31885
|
+
function sortKey(item) {
|
|
31886
|
+
for (const raw of [item.internalDateIso, item.date]) {
|
|
31887
|
+
if (typeof raw !== "string" || !raw) continue;
|
|
31888
|
+
const t = Date.parse(raw);
|
|
31889
|
+
if (!Number.isNaN(t)) return t;
|
|
31890
|
+
}
|
|
31891
|
+
return Number.NEGATIVE_INFINITY;
|
|
31892
|
+
}
|
|
31893
|
+
function sortNewestFirst(items) {
|
|
31894
|
+
return [...items].sort((a, b) => {
|
|
31895
|
+
const ka = sortKey(a);
|
|
31896
|
+
const kb = sortKey(b);
|
|
31897
|
+
return ka === kb ? 0 : kb - ka;
|
|
31898
|
+
});
|
|
31899
|
+
}
|
|
31900
|
+
var COUNT_PROBE_PAGE_SIZE = 500;
|
|
31901
|
+
async function countMatches(method, itemsKey, query, account) {
|
|
31902
|
+
try {
|
|
31903
|
+
const params = JSON.stringify({
|
|
31904
|
+
userId: "me",
|
|
31905
|
+
q: query,
|
|
31906
|
+
maxResults: COUNT_PROBE_PAGE_SIZE,
|
|
31907
|
+
fields: `${itemsKey}/id,nextPageToken`
|
|
31908
|
+
});
|
|
31909
|
+
const raw = await run(["api", "call", "gmail", "v1", method, `--params=${params}`], { account });
|
|
31910
|
+
const parsed = JSON.parse(raw);
|
|
31911
|
+
const items = parsed[itemsKey];
|
|
31912
|
+
if (!Array.isArray(items)) return {};
|
|
31913
|
+
const more = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "";
|
|
31914
|
+
return more ? { atLeast: items.length } : { total: items.length };
|
|
31915
|
+
} catch {
|
|
31916
|
+
return {};
|
|
31917
|
+
}
|
|
31918
|
+
}
|
|
31919
|
+
async function finalizeGmailSearch(result, options) {
|
|
31920
|
+
const { itemsKey, method, query, account, queryIsExact = true } = options;
|
|
31921
|
+
const first = result.content[0];
|
|
31922
|
+
if (result.isError || first?.type !== "text") return result;
|
|
31923
|
+
let parsed;
|
|
31924
|
+
try {
|
|
31925
|
+
parsed = JSON.parse(first.text);
|
|
31926
|
+
} catch {
|
|
31927
|
+
return result;
|
|
31928
|
+
}
|
|
31929
|
+
const items = parsed[itemsKey];
|
|
31930
|
+
if (!Array.isArray(items)) return result;
|
|
31931
|
+
const sorted = sortNewestFirst(items);
|
|
31932
|
+
const out = { ...parsed, [itemsKey]: sorted };
|
|
31933
|
+
if (hasMorePages(parsed)) {
|
|
31934
|
+
const count = queryIsExact ? await countMatches(method, itemsKey, query, account) : {};
|
|
31935
|
+
annotateTruncation(out, sorted.length, count);
|
|
31936
|
+
}
|
|
31937
|
+
return rawTextResult(JSON.stringify(out));
|
|
31938
|
+
}
|
|
31939
|
+
async function fetchGmailPages(runPage, itemsKey, maxPages, startToken) {
|
|
31940
|
+
const merged = [];
|
|
31941
|
+
let base;
|
|
31942
|
+
let token = startToken;
|
|
31943
|
+
for (let pages = 0; pages < maxPages; pages++) {
|
|
31944
|
+
const result = await runPage(token);
|
|
31945
|
+
const parsed = parsePage(result, itemsKey);
|
|
31946
|
+
if (parsed === void 0) {
|
|
31947
|
+
return base === void 0 ? result : finish(base, itemsKey, merged, token);
|
|
31948
|
+
}
|
|
31949
|
+
base = parsed;
|
|
31950
|
+
merged.push(...parsed[itemsKey]);
|
|
31951
|
+
token = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "" ? parsed.nextPageToken : void 0;
|
|
31952
|
+
if (token === void 0) break;
|
|
31953
|
+
}
|
|
31954
|
+
return finish(base, itemsKey, merged, token);
|
|
31955
|
+
}
|
|
31956
|
+
function parsePage(result, itemsKey) {
|
|
31957
|
+
const first = result.content[0];
|
|
31958
|
+
if (result.isError || first?.type !== "text") return void 0;
|
|
31959
|
+
let parsed;
|
|
31960
|
+
try {
|
|
31961
|
+
parsed = JSON.parse(first.text);
|
|
31962
|
+
} catch {
|
|
31963
|
+
return void 0;
|
|
31964
|
+
}
|
|
31965
|
+
if (parsed === null || typeof parsed !== "object") return void 0;
|
|
31966
|
+
const obj = parsed;
|
|
31967
|
+
return Array.isArray(obj[itemsKey]) ? obj : void 0;
|
|
31968
|
+
}
|
|
31969
|
+
function finish(base, itemsKey, merged, token) {
|
|
31970
|
+
const out = { ...base, [itemsKey]: merged };
|
|
31971
|
+
if (token === void 0) delete out.nextPageToken;
|
|
31972
|
+
else out.nextPageToken = token;
|
|
31973
|
+
return rawTextResult(JSON.stringify(out));
|
|
31974
|
+
}
|
|
31975
|
+
|
|
31833
31976
|
// ../gogcli-mcp/src/tools/gmail.ts
|
|
31834
31977
|
function registerGmailTools(server) {
|
|
31835
31978
|
server.registerTool("gog_gmail_search", {
|
|
31836
|
-
description:
|
|
31979
|
+
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.',
|
|
31837
31980
|
annotations: { readOnlyHint: true },
|
|
31838
31981
|
inputSchema: {
|
|
31839
31982
|
query: external_exports.string().describe("Gmail search query"),
|
|
31840
31983
|
max: external_exports.number().int().optional().describe("Max results to return (default: 10)"),
|
|
31984
|
+
pageToken: pageTokenParam,
|
|
31985
|
+
page: pageAliasParam,
|
|
31986
|
+
maxPages: external_exports.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions ("is there any mail matching X?"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
31987
|
+
all: external_exports.boolean().optional().describe('Fetch every page instead of one. Removes truncation entirely, at the cost of one API round-trip per page \u2014 the reliable way to answer "does any message match?" for a query with few expected hits.'),
|
|
31841
31988
|
fromContact: external_exports.string().optional().describe("Resolve a Google Contact (name or email) to its addresses and AND a from:(addr OR addr) clause onto the query \u2014 saves looking the contact up first when you only know who, not which address."),
|
|
31842
31989
|
account: accountParam
|
|
31843
31990
|
}
|
|
31844
|
-
}, async ({ query, max, fromContact, account }) => {
|
|
31991
|
+
}, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
|
|
31845
31992
|
const args = ["gmail", "search", query];
|
|
31846
31993
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
31994
|
+
if (all) args.push("--all");
|
|
31847
31995
|
if (fromContact) args.push(`--from-contact=${fromContact}`);
|
|
31848
|
-
|
|
31996
|
+
const runPage = (tok) => runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
31997
|
+
const token = resolvePageToken({ pageToken, page });
|
|
31998
|
+
const result = maxPages !== void 0 ? await fetchGmailPages(runPage, "threads", maxPages, token) : await runPage(token);
|
|
31999
|
+
return finalizeGmailSearch(result, {
|
|
32000
|
+
itemsKey: "threads",
|
|
32001
|
+
method: "users.threads.list",
|
|
32002
|
+
query,
|
|
32003
|
+
account,
|
|
32004
|
+
// --from-contact is expanded INSIDE gog, against the People API, so the
|
|
32005
|
+
// query Gmail actually saw is not the one we hold here.
|
|
32006
|
+
queryIsExact: !fromContact
|
|
32007
|
+
});
|
|
31849
32008
|
});
|
|
31850
32009
|
server.registerTool("gog_gmail_get", {
|
|
31851
|
-
description: "Get a Gmail message by ID.",
|
|
32010
|
+
description: "Get a Gmail message by ID. For a long message, sanitizeContent is the cheapest way to keep it in context: it drops the raw MIME payload and the HTML part, which are usually the bulk of the response.",
|
|
31852
32011
|
annotations: { readOnlyHint: true },
|
|
31853
32012
|
inputSchema: {
|
|
31854
32013
|
messageId: external_exports.string().describe("Message ID"),
|
|
31855
32014
|
format: external_exports.enum(["full", "metadata", "raw"]).optional().describe("Message format (default: full)"),
|
|
32015
|
+
// Requires gog >= 0.37.0. Before that (openclaw/gogcli#992) the JSON
|
|
32016
|
+
// carried the headers and body TWICE — once inside `message`, once
|
|
32017
|
+
// copied to the top level — so the flag meant to shrink the payload
|
|
32018
|
+
// enlarged it. MIN_GOG_VERSION is the guard; there is no runtime check.
|
|
32019
|
+
sanitizeContent: external_exports.boolean().optional().describe("Return agent-oriented sanitized content: HTML stripped, HTTP(S) URLs removed, raw Gmail payloads omitted from the JSON. The largest payload-size reduction available here. Note the URL removal is lossy \u2014 omit this when you need to follow a link out of the message."),
|
|
31856
32020
|
account: accountParam
|
|
31857
32021
|
}
|
|
31858
|
-
}, async ({ messageId, format, account }) => {
|
|
32022
|
+
}, async ({ messageId, format, sanitizeContent, account }) => {
|
|
31859
32023
|
const args = ["gmail", "get", messageId];
|
|
31860
32024
|
if (format) args.push(`--format=${format}`);
|
|
32025
|
+
if (sanitizeContent) args.push("--sanitize-content");
|
|
31861
32026
|
return runOrDiagnose(args, { account });
|
|
31862
32027
|
});
|
|
31863
32028
|
server.registerTool("gog_gmail_send", {
|
|
@@ -31896,7 +32061,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
|
31896
32061
|
);
|
|
31897
32062
|
|
|
31898
32063
|
// ../gogcli-mcp/src/server.ts
|
|
31899
|
-
var VERSION = true ? "2.
|
|
32064
|
+
var VERSION = true ? "2.24.0" : "0.0.0";
|
|
31900
32065
|
|
|
31901
32066
|
// ../gogcli-mcp/src/auth-log.ts
|
|
31902
32067
|
var FAILURES = /* @__PURE__ */ new Set([
|
|
@@ -33464,15 +33629,17 @@ function registerExtraGmailTools(server) {
|
|
|
33464
33629
|
inputSchema: {
|
|
33465
33630
|
since: external_exports.string().optional().describe("Start history ID"),
|
|
33466
33631
|
max: external_exports.number().optional().describe("Max results (default: 100)"),
|
|
33467
|
-
|
|
33632
|
+
pageToken: pageTokenParam,
|
|
33633
|
+
page: pageAliasParam,
|
|
33468
33634
|
all: external_exports.boolean().optional().describe("Fetch all pages"),
|
|
33469
33635
|
account: accountParam
|
|
33470
33636
|
}
|
|
33471
|
-
}, async ({ since, max, page, all, account }) => {
|
|
33637
|
+
}, async ({ since, max, pageToken, page, all, account }) => {
|
|
33472
33638
|
const args = ["gmail", "history"];
|
|
33473
33639
|
if (since) args.push(`--since=${since}`);
|
|
33474
33640
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
33475
|
-
|
|
33641
|
+
const token = resolvePageToken({ pageToken, page });
|
|
33642
|
+
if (token) args.push(`--page=${token}`);
|
|
33476
33643
|
if (all) args.push("--all");
|
|
33477
33644
|
return runOrDiagnose(args, { account });
|
|
33478
33645
|
});
|
|
@@ -33567,7 +33734,7 @@ function registerExtraGmailTools(server) {
|
|
|
33567
33734
|
return runOrDiagnose(args, { account });
|
|
33568
33735
|
});
|
|
33569
33736
|
server.registerTool("gog_gmail_thread_get", {
|
|
33570
|
-
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. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id \u2014 pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<\u2026@host>` value used in In-Reply-To/References) \u2014 don't confuse either with the `threadId`. To reply to the thread itself, pass the thread's id as replyToThreadId on gog_gmail_drafts_create.",
|
|
33737
|
+
description: "Get a Gmail thread with all messages. THIS IS THE CORRECT TOOL WHEN YOU ALREADY KNOW THE threadId \u2014 it returns the thread in full, so unlike a search it can never be truncated, mis-ranked, or come back empty because the query missed. Never re-discover a known thread with gog_gmail_search; read it here. 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. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id \u2014 pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<\u2026@host>` value used in In-Reply-To/References) \u2014 don't confuse either with the `threadId`. To reply to the thread itself, pass the thread's id as replyToThreadId on gog_gmail_drafts_create.",
|
|
33571
33738
|
annotations: { readOnlyHint: true },
|
|
33572
33739
|
inputSchema: {
|
|
33573
33740
|
threadId: external_exports.string().describe("Gmail thread ID"),
|
|
@@ -33692,17 +33859,19 @@ function registerExtraGmailTools(server) {
|
|
|
33692
33859
|
annotations: { readOnlyHint: true },
|
|
33693
33860
|
inputSchema: {
|
|
33694
33861
|
max: external_exports.number().optional().describe("Max results (default: 20)"),
|
|
33695
|
-
|
|
33862
|
+
pageToken: pageTokenParam,
|
|
33863
|
+
page: pageAliasParam,
|
|
33696
33864
|
all: external_exports.boolean().optional().describe("Fetch all pages"),
|
|
33697
33865
|
enrich: external_exports.boolean().optional().describe(
|
|
33698
33866
|
"Add subject, from and internalDateIso to each draft. Costs ONE extra gog invocation (`gmail messages search in:drafts`) regardless of how many drafts there are \u2014 but that single command makes gog fetch every matching draft server-side at concurrency 10, so Google reads and wall-clock are linear in the result count even though gog spawns are not. Narrow `max` before enabling it. If the search fails the listing silently degrades to the free fields rather than erroring."
|
|
33699
33867
|
),
|
|
33700
33868
|
account: accountParam
|
|
33701
33869
|
}
|
|
33702
|
-
}, async ({ max, page, all, enrich, account }) => {
|
|
33870
|
+
}, async ({ max, pageToken, page, all, enrich, account }) => {
|
|
33703
33871
|
const args = ["gmail", "drafts", "list"];
|
|
33704
33872
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
33705
|
-
|
|
33873
|
+
const token = resolvePageToken({ pageToken, page });
|
|
33874
|
+
if (token) args.push(`--page=${token}`);
|
|
33706
33875
|
if (all) args.push("--all");
|
|
33707
33876
|
const result = await runOrDiagnose(args, { account });
|
|
33708
33877
|
let parsed;
|
|
@@ -33720,7 +33889,7 @@ function registerExtraGmailTools(server) {
|
|
|
33720
33889
|
if (enrich) {
|
|
33721
33890
|
const searchArgs = ["gmail", "messages", "search", "in:drafts", `--max=${max ?? GOG_DRAFTS_LIST_DEFAULT_MAX}`];
|
|
33722
33891
|
if (all) searchArgs.push("--all");
|
|
33723
|
-
if (
|
|
33892
|
+
if (token) searchArgs.push(`--page=${token}`);
|
|
33724
33893
|
searchArgs.push("--include-attachments=false", "--use-indexed-attachment-ids=false");
|
|
33725
33894
|
try {
|
|
33726
33895
|
const messages = JSON.parse(await runNormalized(searchArgs, { account })).messages;
|
|
@@ -34070,7 +34239,7 @@ function registerExtraGmailTools(server) {
|
|
|
34070
34239
|
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
34071
34240
|
}
|
|
34072
34241
|
server.registerTool("gog_gmail_reply", {
|
|
34073
|
-
description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage
|
|
34242
|
+
description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage this same reply without sending it use gog_gmail_drafts_reply, which composes exactly what this tool would send.',
|
|
34074
34243
|
annotations: { destructiveHint: true },
|
|
34075
34244
|
inputSchema: replySchema
|
|
34076
34245
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -34079,7 +34248,7 @@ function registerExtraGmailTools(server) {
|
|
|
34079
34248
|
return runOrDiagnose(args, { account });
|
|
34080
34249
|
});
|
|
34081
34250
|
server.registerTool("gog_gmail_reply_all", {
|
|
34082
|
-
description: 'Reply to all participants of a Gmail message (sender plus every To/Cc recipient). Same inherited "Re:" subject and quoting as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all.',
|
|
34251
|
+
description: 'Reply to all participants of a Gmail message (sender plus every To/Cc recipient). Same inherited "Re:" subject and quoting as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all. To stage it without sending use gog_gmail_drafts_reply_all.',
|
|
34083
34252
|
annotations: { destructiveHint: true },
|
|
34084
34253
|
inputSchema: replySchema
|
|
34085
34254
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -34087,6 +34256,46 @@ function registerExtraGmailTools(server) {
|
|
|
34087
34256
|
appendReplyFlags(args, flags);
|
|
34088
34257
|
return runOrDiagnose(args, { account });
|
|
34089
34258
|
});
|
|
34259
|
+
const draftReplyNote = ' Composes exactly what gog_gmail_reply%s would send \u2014 inherited recipients, "Re:" subject and quoted original \u2014 but SAVES IT AS A DRAFT instead of sending. Nothing leaves the mailbox; send it later with gog_gmail_drafts_send, or edit it first with gog_gmail_drafts_update (which overwrites the whole body, quote included \u2014 read the draft back before editing).';
|
|
34260
|
+
server.registerTool("gog_gmail_drafts_reply", {
|
|
34261
|
+
description: "Save a reply to a Gmail message as a draft (to the original sender only)." + draftReplyNote.replace("%s", "") + " Prefer this over gog_gmail_drafts_create + replyToMessageId when the draft is a real reply: that route threads the draft but leaves recipients and quoting for you to reconstruct.",
|
|
34262
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull }
|
|
34263
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
34264
|
+
const args = ["gmail", "drafts", "reply", messageId];
|
|
34265
|
+
appendReplyFlags(args, flags);
|
|
34266
|
+
return writeDraft(args, account, returnFull);
|
|
34267
|
+
});
|
|
34268
|
+
server.registerTool("gog_gmail_drafts_reply_all", {
|
|
34269
|
+
description: "Save a reply-all to a Gmail message as a draft (sender plus every To/Cc recipient)." + draftReplyNote.replace("%s", "_all") + " Use the remove flag to drop recipients BEFORE the draft exists, rather than editing them out afterwards.",
|
|
34270
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull }
|
|
34271
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
34272
|
+
const args = ["gmail", "drafts", "reply-all", messageId];
|
|
34273
|
+
appendReplyFlags(args, flags);
|
|
34274
|
+
return writeDraft(args, account, returnFull);
|
|
34275
|
+
});
|
|
34276
|
+
server.registerTool("gog_gmail_drafts_forward", {
|
|
34277
|
+
description: "Save a forward of a Gmail message as a draft. Same composition as gog_gmail_forward \u2014 the original message quoted below an optional note, with its attachments carried over \u2014 but nothing is sent. Unlike gog_gmail_forward, `to` is OPTIONAL here: omit it to stage a recipient-less forward as an accidental-send guard, then add recipients with gog_gmail_drafts_update before gog_gmail_drafts_send.",
|
|
34278
|
+
inputSchema: {
|
|
34279
|
+
messageId: external_exports.string().describe("Gmail message ID to forward"),
|
|
34280
|
+
to: external_exports.string().optional().describe("Recipients (comma-separated). Optional for a draft \u2014 omit to stage the forward without recipients."),
|
|
34281
|
+
cc: external_exports.string().optional().describe("CC recipients (comma-separated)"),
|
|
34282
|
+
bcc: external_exports.string().optional().describe("BCC recipients (comma-separated)"),
|
|
34283
|
+
note: external_exports.string().optional().describe("Introductory text above the forwarded message"),
|
|
34284
|
+
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
34285
|
+
skipAttachments: external_exports.boolean().optional().describe("Do not include original attachments"),
|
|
34286
|
+
returnFull: draftWriteSchema.returnFull,
|
|
34287
|
+
account: accountParam
|
|
34288
|
+
}
|
|
34289
|
+
}, async ({ messageId, to, cc, bcc, note, from, skipAttachments, returnFull, account }) => {
|
|
34290
|
+
const args = ["gmail", "drafts", "forward", messageId];
|
|
34291
|
+
if (to) args.push(`--to=${to}`);
|
|
34292
|
+
if (cc) args.push(`--cc=${cc}`);
|
|
34293
|
+
if (bcc) args.push(`--bcc=${bcc}`);
|
|
34294
|
+
if (note) args.push(payloadArg("note", "note-file", note));
|
|
34295
|
+
if (from) args.push(`--from=${from}`);
|
|
34296
|
+
if (skipAttachments) args.push("--skip-attachments");
|
|
34297
|
+
return writeDraft(args, account, returnFull);
|
|
34298
|
+
});
|
|
34090
34299
|
server.registerTool("gog_gmail_autoreply", {
|
|
34091
34300
|
description: "Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.",
|
|
34092
34301
|
annotations: { destructiveHint: true },
|
|
@@ -34121,12 +34330,14 @@ function registerExtraGmailTools(server) {
|
|
|
34121
34330
|
return runOrDiagnose(args, { account });
|
|
34122
34331
|
});
|
|
34123
34332
|
server.registerTool("gog_gmail_messages_search", {
|
|
34124
|
-
description:
|
|
34333
|
+
description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message. Results are ALWAYS newest-first by Gmail\'s internalDate \u2014 the wrapper sorts them, so the first result is the most recent match. IMPORTANT \u2014 a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist 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 first. If you already know the thread, read it with gog_gmail_thread_get instead of searching for it.',
|
|
34125
34334
|
annotations: { readOnlyHint: true },
|
|
34126
34335
|
inputSchema: {
|
|
34127
34336
|
query: external_exports.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
|
|
34128
34337
|
max: external_exports.number().optional().describe("Max results"),
|
|
34129
|
-
|
|
34338
|
+
pageToken: pageTokenParam,
|
|
34339
|
+
page: pageAliasParam,
|
|
34340
|
+
maxPages: external_exports.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions ("is there any mail matching X?"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
34130
34341
|
all: external_exports.boolean().optional().describe("Fetch all pages"),
|
|
34131
34342
|
includeBody: external_exports.boolean().optional().describe("Include the decoded message body in each result"),
|
|
34132
34343
|
full: external_exports.boolean().optional().describe("Show full message bodies without truncation (implies includeBody)"),
|
|
@@ -34135,17 +34346,24 @@ function registerExtraGmailTools(server) {
|
|
|
34135
34346
|
useIndexedAttachmentIds: external_exports.boolean().optional().describe("Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId` (stable across calls, unlike the id). Only has an effect alongside includeAttachments or includeBody."),
|
|
34136
34347
|
account: accountParam
|
|
34137
34348
|
}
|
|
34138
|
-
}, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
34349
|
+
}, async ({ query, max, pageToken, page, maxPages, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
34139
34350
|
const args = ["gmail", "messages", "search", query];
|
|
34140
34351
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
34141
|
-
if (page) args.push(`--page=${page}`);
|
|
34142
34352
|
if (all) args.push("--all");
|
|
34143
34353
|
if (includeBody) args.push("--include-body");
|
|
34144
34354
|
if (full) args.push("--full");
|
|
34145
34355
|
if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
|
|
34146
34356
|
args.push(includeAttachments ? "--include-attachments" : "--include-attachments=false");
|
|
34147
34357
|
args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
|
|
34148
|
-
|
|
34358
|
+
const runPage = (tok) => runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
34359
|
+
const token = resolvePageToken({ pageToken, page });
|
|
34360
|
+
const result = maxPages !== void 0 ? await fetchGmailPages(runPage, "messages", maxPages, token) : await runPage(token);
|
|
34361
|
+
return finalizeGmailSearch(result, {
|
|
34362
|
+
itemsKey: "messages",
|
|
34363
|
+
method: "users.messages.list",
|
|
34364
|
+
query,
|
|
34365
|
+
account
|
|
34366
|
+
});
|
|
34149
34367
|
});
|
|
34150
34368
|
server.registerTool("gog_gmail_labels_style", {
|
|
34151
34369
|
description: "Change a user label's color or visibility (background/text color from Gmail's palette, label-list and message-list visibility).",
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-gmail",
|
|
5
5
|
"display_name": "gogcli (Gmail)",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.24.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",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"name": "gog_gmail_search",
|
|
94
|
-
"description": "Search Gmail threads using Gmail query syntax"
|
|
94
|
+
"description": "Search Gmail threads using Gmail query syntax; newest-first, and flags a truncated result set"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"name": "gog_gmail_get",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"name": "gog_gmail_thread_get",
|
|
154
|
-
"description": "Get a thread with all messages"
|
|
154
|
+
"description": "Get a Gmail thread with all messages — the right tool when the threadId is already known"
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
"name": "gog_gmail_thread_modify",
|
|
@@ -213,6 +213,18 @@
|
|
|
213
213
|
"name": "gog_gmail_drafts_diff",
|
|
214
214
|
"description": "Diff two named drafts: what each body has that the other lost, how their threading differs, and (only on a link back to the original or on agreement over unquoted text, with evidence) whether one replaced the other"
|
|
215
215
|
},
|
|
216
|
+
{
|
|
217
|
+
"name": "gog_gmail_drafts_reply",
|
|
218
|
+
"description": "Save a reply to a Gmail message as a draft (inherited recipients, subject and quote; never sends)"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "gog_gmail_drafts_reply_all",
|
|
222
|
+
"description": "Save a reply-all to a Gmail message as a draft (never sends)"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "gog_gmail_drafts_forward",
|
|
226
|
+
"description": "Save a forward of a Gmail message as a draft; recipients are optional (never sends)"
|
|
227
|
+
},
|
|
216
228
|
{
|
|
217
229
|
"name": "gog_gmail_import",
|
|
218
230
|
"description": "Import an RFC822/EML message into the mailbox (keeps its original headers and date; does not send)"
|
|
@@ -235,7 +247,7 @@
|
|
|
235
247
|
},
|
|
236
248
|
{
|
|
237
249
|
"name": "gog_gmail_messages_search",
|
|
238
|
-
"description": "Search individual messages
|
|
250
|
+
"description": "Search individual messages; newest-first, and flags a truncated result set"
|
|
239
251
|
},
|
|
240
252
|
{
|
|
241
253
|
"name": "gog_gmail_labels_style",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-gmail",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.24.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
|
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { rawTextResult, textResult, errorResult } from '@chrischall/mcp-utils';
|
|
5
|
-
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor, normalizeTimestamps } from '../../../gogcli-mcp/src/lib.js';
|
|
5
|
+
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor, normalizeTimestamps, finalizeGmailSearch, fetchGmailPages, pageTokenParam, pageAliasParam, resolvePageToken} from '../../../gogcli-mcp/src/lib.js';
|
|
6
6
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
7
7
|
|
|
8
8
|
// gog rejects an inline flag together with its --*-file twin — `gmail drafts
|
|
@@ -2365,15 +2365,17 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2365
2365
|
inputSchema: {
|
|
2366
2366
|
since: z.string().optional().describe('Start history ID'),
|
|
2367
2367
|
max: z.number().optional().describe('Max results (default: 100)'),
|
|
2368
|
-
|
|
2368
|
+
pageToken: pageTokenParam,
|
|
2369
|
+
page: pageAliasParam,
|
|
2369
2370
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
2370
2371
|
account: accountParam,
|
|
2371
2372
|
},
|
|
2372
|
-
}, async ({ since, max, page, all, account }) => {
|
|
2373
|
+
}, async ({ since, max, pageToken, page, all, account }) => {
|
|
2373
2374
|
const args = ['gmail', 'history'];
|
|
2374
2375
|
if (since) args.push(`--since=${since}`);
|
|
2375
2376
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
2376
|
-
|
|
2377
|
+
const token = resolvePageToken({ pageToken, page });
|
|
2378
|
+
if (token) args.push(`--page=${token}`);
|
|
2377
2379
|
if (all) args.push('--all');
|
|
2378
2380
|
return runOrDiagnose(args, { account });
|
|
2379
2381
|
});
|
|
@@ -2476,7 +2478,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2476
2478
|
});
|
|
2477
2479
|
|
|
2478
2480
|
server.registerTool('gog_gmail_thread_get', {
|
|
2479
|
-
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. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id — pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<…@host>` value used in In-Reply-To/References) — don\'t confuse either with the `threadId`. To reply to the thread itself, pass the thread\'s id as replyToThreadId on gog_gmail_drafts_create.',
|
|
2481
|
+
description: 'Get a Gmail thread with all messages. THIS IS THE CORRECT TOOL WHEN YOU ALREADY KNOW THE threadId — it returns the thread in full, so unlike a search it can never be truncated, mis-ranked, or come back empty because the query missed. Never re-discover a known thread with gog_gmail_search; read it here. 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. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id — pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<…@host>` value used in In-Reply-To/References) — don\'t confuse either with the `threadId`. To reply to the thread itself, pass the thread\'s id as replyToThreadId on gog_gmail_drafts_create.',
|
|
2480
2482
|
annotations: { readOnlyHint: true },
|
|
2481
2483
|
inputSchema: {
|
|
2482
2484
|
threadId: z.string().describe('Gmail thread ID'),
|
|
@@ -2624,7 +2626,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2624
2626
|
annotations: { readOnlyHint: true },
|
|
2625
2627
|
inputSchema: {
|
|
2626
2628
|
max: z.number().optional().describe('Max results (default: 20)'),
|
|
2627
|
-
|
|
2629
|
+
pageToken: pageTokenParam,
|
|
2630
|
+
page: pageAliasParam,
|
|
2628
2631
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
2629
2632
|
enrich: z.boolean().optional().describe(
|
|
2630
2633
|
'Add subject, from and internalDateIso to each draft. Costs ONE extra gog invocation (`gmail messages search in:drafts`) ' +
|
|
@@ -2634,10 +2637,11 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2634
2637
|
),
|
|
2635
2638
|
account: accountParam,
|
|
2636
2639
|
},
|
|
2637
|
-
}, async ({ max, page, all, enrich, account }) => {
|
|
2640
|
+
}, async ({ max, pageToken, page, all, enrich, account }) => {
|
|
2638
2641
|
const args = ['gmail', 'drafts', 'list'];
|
|
2639
2642
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
2640
|
-
|
|
2643
|
+
const token = resolvePageToken({ pageToken, page });
|
|
2644
|
+
if (token) args.push(`--page=${token}`);
|
|
2641
2645
|
if (all) args.push('--all');
|
|
2642
2646
|
const result = await runOrDiagnose(args, { account });
|
|
2643
2647
|
|
|
@@ -2668,7 +2672,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2668
2672
|
// list is on page N: an extra gog spawn that joins ZERO rows and still
|
|
2669
2673
|
// reported applied:true. The token is a drafts-list cursor, so it is only
|
|
2670
2674
|
// meaningful to the paged search.
|
|
2671
|
-
if (
|
|
2675
|
+
if (token) searchArgs.push(`--page=${token}`);
|
|
2672
2676
|
// Both PINNED for the same reason as gog_gmail_messages_search: the env
|
|
2673
2677
|
// vars behind them change the result shape (and the per-message cost).
|
|
2674
2678
|
searchArgs.push('--include-attachments=false', '--use-indexed-attachment-ids=false');
|
|
@@ -3221,7 +3225,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3221
3225
|
}
|
|
3222
3226
|
|
|
3223
3227
|
server.registerTool('gog_gmail_reply', {
|
|
3224
|
-
description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage
|
|
3228
|
+
description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage this same reply without sending it use gog_gmail_drafts_reply, which composes exactly what this tool would send.',
|
|
3225
3229
|
annotations: { destructiveHint: true },
|
|
3226
3230
|
inputSchema: replySchema,
|
|
3227
3231
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -3231,7 +3235,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3231
3235
|
});
|
|
3232
3236
|
|
|
3233
3237
|
server.registerTool('gog_gmail_reply_all', {
|
|
3234
|
-
description: 'Reply to all participants of a Gmail message (sender plus every To/Cc recipient). Same inherited "Re:" subject and quoting as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all.',
|
|
3238
|
+
description: 'Reply to all participants of a Gmail message (sender plus every To/Cc recipient). Same inherited "Re:" subject and quoting as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all. To stage it without sending use gog_gmail_drafts_reply_all.',
|
|
3235
3239
|
annotations: { destructiveHint: true },
|
|
3236
3240
|
inputSchema: replySchema,
|
|
3237
3241
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -3240,6 +3244,76 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3240
3244
|
return runOrDiagnose(args, { account });
|
|
3241
3245
|
});
|
|
3242
3246
|
|
|
3247
|
+
// gog >= 0.36.0: the draft-side twins of reply / reply-all / forward. They
|
|
3248
|
+
// take the SAME flag set as the send-side commands and share the composition
|
|
3249
|
+
// path with them, so the schemas above are reused verbatim rather than
|
|
3250
|
+
// re-declared — the only difference is the subcommand and that NOTHING IS
|
|
3251
|
+
// SENT.
|
|
3252
|
+
//
|
|
3253
|
+
// These exist because staging a reply used to mean gog_gmail_drafts_create
|
|
3254
|
+
// with replyToMessageId/replyToThreadId, which threads the draft but does NOT
|
|
3255
|
+
// inherit the original's recipients or quote its body — the caller had to
|
|
3256
|
+
// rebuild both by hand, and a missed Cc is invisible until the draft goes
|
|
3257
|
+
// out. Here the inheritance is gog's, identical to what the send path would
|
|
3258
|
+
// have produced.
|
|
3259
|
+
const draftReplyNote =
|
|
3260
|
+
' Composes exactly what gog_gmail_reply%s would send — inherited recipients, "Re:" subject and quoted ' +
|
|
3261
|
+
'original — but SAVES IT AS A DRAFT instead of sending. Nothing leaves the mailbox; send it later with ' +
|
|
3262
|
+
'gog_gmail_drafts_send, or edit it first with gog_gmail_drafts_update (which overwrites the whole body, ' +
|
|
3263
|
+
'quote included — read the draft back before editing).';
|
|
3264
|
+
|
|
3265
|
+
server.registerTool('gog_gmail_drafts_reply', {
|
|
3266
|
+
description:
|
|
3267
|
+
'Save a reply to a Gmail message as a draft (to the original sender only).' + draftReplyNote.replace('%s', '') +
|
|
3268
|
+
' Prefer this over gog_gmail_drafts_create + replyToMessageId when the draft is a real reply: that route threads ' +
|
|
3269
|
+
'the draft but leaves recipients and quoting for you to reconstruct.',
|
|
3270
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull },
|
|
3271
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
3272
|
+
const args: GogArg[] = ['gmail', 'drafts', 'reply', messageId];
|
|
3273
|
+
appendReplyFlags(args, flags);
|
|
3274
|
+
return writeDraft(args, account, returnFull);
|
|
3275
|
+
});
|
|
3276
|
+
|
|
3277
|
+
server.registerTool('gog_gmail_drafts_reply_all', {
|
|
3278
|
+
description:
|
|
3279
|
+
'Save a reply-all to a Gmail message as a draft (sender plus every To/Cc recipient).' +
|
|
3280
|
+
draftReplyNote.replace('%s', '_all') +
|
|
3281
|
+
' Use the remove flag to drop recipients BEFORE the draft exists, rather than editing them out afterwards.',
|
|
3282
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull },
|
|
3283
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
3284
|
+
const args: GogArg[] = ['gmail', 'drafts', 'reply-all', messageId];
|
|
3285
|
+
appendReplyFlags(args, flags);
|
|
3286
|
+
return writeDraft(args, account, returnFull);
|
|
3287
|
+
});
|
|
3288
|
+
|
|
3289
|
+
server.registerTool('gog_gmail_drafts_forward', {
|
|
3290
|
+
description:
|
|
3291
|
+
'Save a forward of a Gmail message as a draft. Same composition as gog_gmail_forward — the original ' +
|
|
3292
|
+
'message quoted below an optional note, with its attachments carried over — but nothing is sent. ' +
|
|
3293
|
+
'Unlike gog_gmail_forward, `to` is OPTIONAL here: omit it to stage a recipient-less forward as an ' +
|
|
3294
|
+
'accidental-send guard, then add recipients with gog_gmail_drafts_update before gog_gmail_drafts_send.',
|
|
3295
|
+
inputSchema: {
|
|
3296
|
+
messageId: z.string().describe('Gmail message ID to forward'),
|
|
3297
|
+
to: z.string().optional().describe('Recipients (comma-separated). Optional for a draft — omit to stage the forward without recipients.'),
|
|
3298
|
+
cc: z.string().optional().describe('CC recipients (comma-separated)'),
|
|
3299
|
+
bcc: z.string().optional().describe('BCC recipients (comma-separated)'),
|
|
3300
|
+
note: z.string().optional().describe('Introductory text above the forwarded message'),
|
|
3301
|
+
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
3302
|
+
skipAttachments: z.boolean().optional().describe('Do not include original attachments'),
|
|
3303
|
+
returnFull: draftWriteSchema.returnFull,
|
|
3304
|
+
account: accountParam,
|
|
3305
|
+
},
|
|
3306
|
+
}, async ({ messageId, to, cc, bcc, note, from, skipAttachments, returnFull, account }) => {
|
|
3307
|
+
const args: GogArg[] = ['gmail', 'drafts', 'forward', messageId];
|
|
3308
|
+
if (to) args.push(`--to=${to}`);
|
|
3309
|
+
if (cc) args.push(`--cc=${cc}`);
|
|
3310
|
+
if (bcc) args.push(`--bcc=${bcc}`);
|
|
3311
|
+
if (note) args.push(payloadArg('note', 'note-file', note));
|
|
3312
|
+
if (from) args.push(`--from=${from}`);
|
|
3313
|
+
if (skipAttachments) args.push('--skip-attachments');
|
|
3314
|
+
return writeDraft(args, account, returnFull);
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3243
3317
|
server.registerTool('gog_gmail_autoreply', {
|
|
3244
3318
|
description: 'Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.',
|
|
3245
3319
|
annotations: { destructiveHint: true },
|
|
@@ -3278,12 +3352,17 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3278
3352
|
});
|
|
3279
3353
|
|
|
3280
3354
|
server.registerTool('gog_gmail_messages_search', {
|
|
3281
|
-
description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message.'
|
|
3355
|
+
description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message. '
|
|
3356
|
+
+ 'Results are ALWAYS newest-first by Gmail\'s internalDate — the wrapper sorts them, so the first result is the most recent match. '
|
|
3357
|
+
+ 'IMPORTANT — a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist 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 first. '
|
|
3358
|
+
+ 'If you already know the thread, read it with gog_gmail_thread_get instead of searching for it.',
|
|
3282
3359
|
annotations: { readOnlyHint: true },
|
|
3283
3360
|
inputSchema: {
|
|
3284
3361
|
query: z.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
|
|
3285
3362
|
max: z.number().optional().describe('Max results'),
|
|
3286
|
-
|
|
3363
|
+
pageToken: pageTokenParam,
|
|
3364
|
+
page: pageAliasParam,
|
|
3365
|
+
maxPages: z.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions (\"is there any mail matching X?\"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
3287
3366
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
3288
3367
|
includeBody: z.boolean().optional().describe('Include the decoded message body in each result'),
|
|
3289
3368
|
full: z.boolean().optional().describe('Show full message bodies without truncation (implies includeBody)'),
|
|
@@ -3292,10 +3371,9 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3292
3371
|
useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId` (stable across calls, unlike the id). Only has an effect alongside includeAttachments or includeBody.'),
|
|
3293
3372
|
account: accountParam,
|
|
3294
3373
|
},
|
|
3295
|
-
}, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
3374
|
+
}, async ({ query, max, pageToken, page, maxPages, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
3296
3375
|
const args = ['gmail', 'messages', 'search', query];
|
|
3297
3376
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
3298
|
-
if (page) args.push(`--page=${page}`);
|
|
3299
3377
|
if (all) args.push('--all');
|
|
3300
3378
|
if (includeBody) args.push('--include-body');
|
|
3301
3379
|
if (full) args.push('--full');
|
|
@@ -3305,7 +3383,19 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3305
3383
|
// nothing in the arg array to show for it. See gog_gmail_thread_get.
|
|
3306
3384
|
args.push(includeAttachments ? '--include-attachments' : '--include-attachments=false');
|
|
3307
3385
|
args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
|
|
3308
|
-
|
|
3386
|
+
// See gog_gmail_search: the cursor rides per page so the walk can advance it.
|
|
3387
|
+
const runPage = (tok: string | undefined) =>
|
|
3388
|
+
runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
3389
|
+
const token = resolvePageToken({ pageToken, page });
|
|
3390
|
+
const result = maxPages !== undefined
|
|
3391
|
+
? await fetchGmailPages(runPage, 'messages', maxPages, token)
|
|
3392
|
+
: await runPage(token);
|
|
3393
|
+
return finalizeGmailSearch(result, {
|
|
3394
|
+
itemsKey: 'messages',
|
|
3395
|
+
method: 'users.messages.list',
|
|
3396
|
+
query,
|
|
3397
|
+
account,
|
|
3398
|
+
});
|
|
3309
3399
|
});
|
|
3310
3400
|
|
|
3311
3401
|
server.registerTool('gog_gmail_labels_style', {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { registerExtraGmailTools } from '../../src/tools/gmail-extra.js';
|
|
3
3
|
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import * as runner from '../../../gogcli-mcp/src/runner.js';
|
|
4
5
|
import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
|
|
5
6
|
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
6
7
|
|
|
@@ -14,6 +15,16 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
|
14
15
|
};
|
|
15
16
|
});
|
|
16
17
|
|
|
18
|
+
// finalizeGmailSearch reaches for runner.run DIRECTLY (not the lib re-export
|
|
19
|
+
// the mock above replaces) to count matches behind a truncated result set.
|
|
20
|
+
// Without this the probe would spawn the real `gog` and hit the live Gmail API
|
|
21
|
+
// from a unit test. Only `run` is replaced — runExecutor is a real
|
|
22
|
+
// AsyncLocalStorage the connector-shape tests depend on.
|
|
23
|
+
vi.mock('../../../gogcli-mcp/src/runner.js', async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal<typeof runner>();
|
|
25
|
+
return { ...actual, run: vi.fn() };
|
|
26
|
+
});
|
|
27
|
+
|
|
17
28
|
let harness: TestHarness;
|
|
18
29
|
|
|
19
30
|
beforeEach(async () => {
|
|
@@ -21,6 +32,9 @@ beforeEach(async () => {
|
|
|
21
32
|
vi.mocked(lib.run).mockResolvedValue('{}');
|
|
22
33
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
23
34
|
vi.mocked(lib.diagnose).mockResolvedValue(errorResult('diagnosed'));
|
|
35
|
+
// Default: the match-count probe finds nothing to report, so no test depends
|
|
36
|
+
// on a live call. Tests that care stub it explicitly.
|
|
37
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('no count probe stubbed'));
|
|
24
38
|
harness = await createTestHarness(registerExtraGmailTools);
|
|
25
39
|
});
|
|
26
40
|
|
|
@@ -1655,6 +1669,139 @@ describe('gog_gmail_reply_all', () => {
|
|
|
1655
1669
|
});
|
|
1656
1670
|
});
|
|
1657
1671
|
|
|
1672
|
+
// gog 0.36.0 (openclaw/gogcli#977) added the draft-side twins of reply /
|
|
1673
|
+
// reply-all / forward. The point of these tests is the SUBCOMMAND: the flag
|
|
1674
|
+
// handling is the send path's, shared verbatim, and a copy of it here would
|
|
1675
|
+
// only re-assert what the reply tests above already pin. What is new — and what
|
|
1676
|
+
// a regression would silently break — is that these route to `drafts <verb>`
|
|
1677
|
+
// and therefore never send.
|
|
1678
|
+
describe('gog_gmail_drafts_reply', () => {
|
|
1679
|
+
it('routes to gmail drafts reply, not the sending reply', async () => {
|
|
1680
|
+
await harness.callTool('gog_gmail_drafts_reply', { messageId: 'm1', body: 'Thanks' });
|
|
1681
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1682
|
+
['gmail', 'drafts', 'reply', 'm1', '--body=Thanks', '--auto-from-addressed-alias=false'],
|
|
1683
|
+
{ account: undefined },
|
|
1684
|
+
);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
it('passes the shared reply flag set through unchanged', async () => {
|
|
1688
|
+
await harness.callTool('gog_gmail_drafts_reply', {
|
|
1689
|
+
messageId: 'm1',
|
|
1690
|
+
body: 'Hi',
|
|
1691
|
+
to: ['a@b.com'],
|
|
1692
|
+
cc: ['cc@x.com'],
|
|
1693
|
+
remove: ['old@x.com'],
|
|
1694
|
+
subject: 'New subject',
|
|
1695
|
+
noQuote: true,
|
|
1696
|
+
attach: ['/tmp/a.pdf'],
|
|
1697
|
+
from: 'me@x.com',
|
|
1698
|
+
signature: true,
|
|
1699
|
+
account: 'me@gmail.com',
|
|
1700
|
+
});
|
|
1701
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1702
|
+
[
|
|
1703
|
+
'gmail', 'drafts', 'reply', 'm1',
|
|
1704
|
+
'--body=Hi',
|
|
1705
|
+
'--to=a@b.com',
|
|
1706
|
+
'--cc=cc@x.com',
|
|
1707
|
+
'--remove=old@x.com',
|
|
1708
|
+
'--subject=New subject',
|
|
1709
|
+
'--no-quote',
|
|
1710
|
+
'--attach=/tmp/a.pdf',
|
|
1711
|
+
'--from=me@x.com',
|
|
1712
|
+
'--signature',
|
|
1713
|
+
'--auto-from-addressed-alias=false',
|
|
1714
|
+
],
|
|
1715
|
+
{ account: 'me@gmail.com' },
|
|
1716
|
+
);
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it('returnFull re-fetches the saved draft and never reaches the CLI as a flag', async () => {
|
|
1720
|
+
vi.mocked(lib.runOrDiagnose)
|
|
1721
|
+
.mockResolvedValueOnce(rawTextResult('{"draftId":"d9"}'))
|
|
1722
|
+
.mockResolvedValueOnce(rawTextResult('{"id":"d9","message":{"subject":"Re: Hi"}}'));
|
|
1723
|
+
const result = await harness.callTool('gog_gmail_drafts_reply', {
|
|
1724
|
+
messageId: 'm1', body: 'Hi', returnFull: true,
|
|
1725
|
+
});
|
|
1726
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(1,
|
|
1727
|
+
['gmail', 'drafts', 'reply', 'm1', '--body=Hi', '--auto-from-addressed-alias=false'], { account: undefined });
|
|
1728
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
1729
|
+
['gmail', 'drafts', 'get', 'd9', '--use-indexed-attachment-ids=false'], { account: undefined });
|
|
1730
|
+
expect(result.content[0].text).toContain('"subject":"Re: Hi"');
|
|
1731
|
+
});
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
describe('gog_gmail_drafts_reply_all', () => {
|
|
1735
|
+
it('routes to gmail drafts reply-all', async () => {
|
|
1736
|
+
await harness.callTool('gog_gmail_drafts_reply_all', { messageId: 'm1', body: 'Thanks all' });
|
|
1737
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1738
|
+
['gmail', 'drafts', 'reply-all', 'm1', '--body=Thanks all', '--auto-from-addressed-alias=false'],
|
|
1739
|
+
{ account: undefined },
|
|
1740
|
+
);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it('carries repeatable recipient removals onto the draft', async () => {
|
|
1744
|
+
await harness.callTool('gog_gmail_drafts_reply_all', {
|
|
1745
|
+
messageId: 'm1', body: 'Hi', remove: ['drop@y.com', 'also@y.com'],
|
|
1746
|
+
});
|
|
1747
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1748
|
+
[
|
|
1749
|
+
'gmail', 'drafts', 'reply-all', 'm1',
|
|
1750
|
+
'--body=Hi',
|
|
1751
|
+
'--remove=drop@y.com',
|
|
1752
|
+
'--remove=also@y.com',
|
|
1753
|
+
'--auto-from-addressed-alias=false',
|
|
1754
|
+
],
|
|
1755
|
+
{ account: undefined },
|
|
1756
|
+
);
|
|
1757
|
+
});
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
describe('gog_gmail_drafts_forward', () => {
|
|
1761
|
+
it('omits --to entirely when no recipients are given', async () => {
|
|
1762
|
+
await harness.callTool('gog_gmail_drafts_forward', { messageId: 'm1' });
|
|
1763
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1764
|
+
['gmail', 'drafts', 'forward', 'm1'],
|
|
1765
|
+
{ account: undefined },
|
|
1766
|
+
);
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
it('passes every forward flag', async () => {
|
|
1770
|
+
await harness.callTool('gog_gmail_drafts_forward', {
|
|
1771
|
+
messageId: 'm1',
|
|
1772
|
+
to: 'a@b.com,c@d.com',
|
|
1773
|
+
cc: 'cc@x.com',
|
|
1774
|
+
bcc: 'bcc@x.com',
|
|
1775
|
+
note: 'FYI',
|
|
1776
|
+
from: 'me@x.com',
|
|
1777
|
+
skipAttachments: true,
|
|
1778
|
+
account: 'me@gmail.com',
|
|
1779
|
+
});
|
|
1780
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1781
|
+
[
|
|
1782
|
+
'gmail', 'drafts', 'forward', 'm1',
|
|
1783
|
+
'--to=a@b.com,c@d.com',
|
|
1784
|
+
'--cc=cc@x.com',
|
|
1785
|
+
'--bcc=bcc@x.com',
|
|
1786
|
+
'--note=FYI',
|
|
1787
|
+
'--from=me@x.com',
|
|
1788
|
+
'--skip-attachments',
|
|
1789
|
+
],
|
|
1790
|
+
{ account: 'me@gmail.com' },
|
|
1791
|
+
);
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it('returnFull re-fetches the saved forward draft', async () => {
|
|
1795
|
+
vi.mocked(lib.runOrDiagnose)
|
|
1796
|
+
.mockResolvedValueOnce(rawTextResult('{"draftId":"d7"}'))
|
|
1797
|
+
.mockResolvedValueOnce(rawTextResult('{"id":"d7","message":{"subject":"Fwd: Hi"}}'));
|
|
1798
|
+
const result = await harness.callTool('gog_gmail_drafts_forward', { messageId: 'm1', returnFull: true });
|
|
1799
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
1800
|
+
['gmail', 'drafts', 'get', 'd7', '--use-indexed-attachment-ids=false'], { account: undefined });
|
|
1801
|
+
expect(result.content[0].text).toContain('"subject":"Fwd: Hi"');
|
|
1802
|
+
});
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1658
1805
|
describe('gog_gmail_autoreply', () => {
|
|
1659
1806
|
it('calls runOrDiagnose with query and --body', async () => {
|
|
1660
1807
|
await harness.callTool('gog_gmail_autoreply', { query: 'is:unread', body: 'Thanks' });
|
|
@@ -1735,7 +1882,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1735
1882
|
await harness.callTool('gog_gmail_messages_search', {
|
|
1736
1883
|
query: 'is:unread',
|
|
1737
1884
|
max: 10,
|
|
1738
|
-
|
|
1885
|
+
pageToken: 'tok',
|
|
1739
1886
|
all: true,
|
|
1740
1887
|
includeBody: true,
|
|
1741
1888
|
full: true,
|
|
@@ -1743,7 +1890,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1743
1890
|
account: 'me@x.com',
|
|
1744
1891
|
});
|
|
1745
1892
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1746
|
-
['gmail', 'messages', 'search', 'is:unread', '--max=10', '--
|
|
1893
|
+
['gmail', 'messages', 'search', 'is:unread', '--max=10', '--all', '--include-body', '--full', '--body-format=html', '--include-attachments=false', '--use-indexed-attachment-ids=false', '--page=tok'],
|
|
1747
1894
|
{ account: 'me@x.com' },
|
|
1748
1895
|
);
|
|
1749
1896
|
});
|
|
@@ -1757,6 +1904,98 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1757
1904
|
});
|
|
1758
1905
|
});
|
|
1759
1906
|
|
|
1907
|
+
describe('page-cursor contract', () => {
|
|
1908
|
+
it('never steers a caller to the deprecated `page` alias', async () => {
|
|
1909
|
+
const { tools } = await harness.client.listTools();
|
|
1910
|
+
const offenders = (tools as { name: string; description?: string }[])
|
|
1911
|
+
.filter((t) => /`page`|\bas page\b/i.test(t.description ?? ''))
|
|
1912
|
+
.map((t) => t.name);
|
|
1913
|
+
expect(offenders).toEqual([]);
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
it('offers the alias wherever pageToken is accepted', async () => {
|
|
1917
|
+
const { tools } = await harness.client.listTools();
|
|
1918
|
+
const withCursor = (tools as { name: string; inputSchema?: { properties?: Record<string, unknown> } }[])
|
|
1919
|
+
.filter((t) => 'pageToken' in (t.inputSchema?.properties ?? {}));
|
|
1920
|
+
expect(withCursor.length).toBeGreaterThan(0);
|
|
1921
|
+
expect(withCursor.filter((t) => !('page' in (t.inputSchema?.properties ?? {}))).map((t) => t.name)).toEqual([]);
|
|
1922
|
+
});
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
describe('gog_gmail_messages_search — the page cursor reaches the API', () => {
|
|
1926
|
+
it('threads a pageToken through to the gog invocation', async () => {
|
|
1927
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', pageToken: 'CURSOR' });
|
|
1928
|
+
const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0] as string[];
|
|
1929
|
+
expect(args).toContain('--page=CURSOR');
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it('still accepts the deprecated page alias, and pageToken wins over it', async () => {
|
|
1933
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', page: 'OLD' });
|
|
1934
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0][0]).toContain('--page=OLD');
|
|
1935
|
+
vi.mocked(lib.runOrDiagnose).mockClear();
|
|
1936
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', pageToken: 'NEW', page: 'OLD' });
|
|
1937
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0][0]).toContain('--page=NEW');
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
it('walks and merges pages under maxPages', async () => {
|
|
1941
|
+
vi.mocked(lib.runOrDiagnose)
|
|
1942
|
+
.mockResolvedValueOnce(rawTextResult(JSON.stringify({ messages: [{ id: 'a' }], nextPageToken: 'T1' })))
|
|
1943
|
+
.mockResolvedValueOnce(rawTextResult(JSON.stringify({ messages: [{ id: 'b' }] })));
|
|
1944
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x', maxPages: 4 });
|
|
1945
|
+
const out = JSON.parse(result.content[0].text as string);
|
|
1946
|
+
expect(out.messages.map((m: { id: string }) => m.id)).toEqual(['a', 'b']);
|
|
1947
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
1948
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[1][0]).toContain('--page=T1');
|
|
1949
|
+
});
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
describe('gog_gmail_messages_search — result finalization', () => {
|
|
1953
|
+
it('sorts results newest-first', async () => {
|
|
1954
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult(JSON.stringify({
|
|
1955
|
+
messages: [
|
|
1956
|
+
{ id: 'old', internalDateIso: '2026-08-01T09:00:00-04:00' },
|
|
1957
|
+
{ id: 'new', internalDateIso: '2026-08-12T12:36:00-04:00' },
|
|
1958
|
+
{ id: 'mid', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
1959
|
+
],
|
|
1960
|
+
nextPageToken: '',
|
|
1961
|
+
})));
|
|
1962
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x' });
|
|
1963
|
+
const out = JSON.parse(result.content[0].text as string);
|
|
1964
|
+
expect(out.messages.map((m: { id: string }) => m.id)).toEqual(['new', 'mid', 'old']);
|
|
1965
|
+
expect(out).not.toHaveProperty('truncated');
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
it('marks a capped result set truncated and counts the real total', async () => {
|
|
1969
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult(JSON.stringify({
|
|
1970
|
+
messages: [{ id: 'a' }, { id: 'b' }],
|
|
1971
|
+
nextPageToken: 'tok',
|
|
1972
|
+
})));
|
|
1973
|
+
vi.mocked(runner.run).mockResolvedValue(JSON.stringify({
|
|
1974
|
+
messages: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }],
|
|
1975
|
+
}));
|
|
1976
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x', max: 2 });
|
|
1977
|
+
const out = JSON.parse(result.content[0].text as string);
|
|
1978
|
+
expect(out.truncated).toBe(true);
|
|
1979
|
+
expect(out.returned).toBe(2);
|
|
1980
|
+
expect(out.totalMatches).toBe(5);
|
|
1981
|
+
expect(out.warning).toBe(
|
|
1982
|
+
'INCOMPLETE RESULT SET: returned 2 of 5 matches. Do not report an absence of results ' +
|
|
1983
|
+
'based on this response. Page with nextPageToken or narrow the query.',
|
|
1984
|
+
);
|
|
1985
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
1986
|
+
['api', 'call', 'gmail', 'v1', 'users.messages.list',
|
|
1987
|
+
'--params={"userId":"me","q":"x","maxResults":500,"fields":"messages/id,nextPageToken"}'],
|
|
1988
|
+
{ account: undefined },
|
|
1989
|
+
);
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
it('leaves output it does not recognise untouched', async () => {
|
|
1993
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('No results'));
|
|
1994
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x' });
|
|
1995
|
+
expect(result.content[0].text).toBe('No results');
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1760
1999
|
describe('gog_gmail_labels_style', () => {
|
|
1761
2000
|
it('calls runOrDiagnose with just the label', async () => {
|
|
1762
2001
|
await harness.callTool('gog_gmail_labels_style', { labelIdOrName: 'Work' });
|