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 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 46 additional dedicated Gmail tools for threads, labels, drafts, attachments, forwarding, autoreply, and bulk operations.
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 (46)
47
+ ## Extra Gmail Tools (49)
48
48
 
49
- Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) — 58 in all.
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) — 58 tools: 8 auth + 4 base Gmail + 46 extra dedicated Gmail tools.
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
- page: external_exports.string().optional().describe("Page token"),
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
- return rawTextResult(await run(["auth", "add", email3, "--services", services], {
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
- return rawTextResult(await run(
31782
- ["auth", "add", email3, "--remote", "--step", "1", "--services", services, "--force-consent"],
31783
- { redactMode: "tokens" }
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
- return rawTextResult(await run(
31804
- [
31805
- "auth",
31806
- "add",
31807
- email3,
31808
- "--remote",
31809
- "--step",
31810
- "2",
31811
- "--auth-url",
31812
- redirectUrl,
31813
- "--services",
31814
- services,
31815
- "--force-consent"
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: `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).`,
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
- return runOrDiagnose(args, { account });
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.23.1" : "0.0.0";
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
- page: external_exports.string().optional().describe("Page token"),
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
- if (page) args.push(`--page=${page}`);
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
- page: external_exports.string().optional().describe("Page token"),
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
- if (page) args.push(`--page=${page}`);
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 (page) searchArgs.push(`--page=${page}`);
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 a reply without sending use gog_gmail_drafts_create.',
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: "Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message.",
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
- page: external_exports.string().optional().describe("Page token"),
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
- return runOrDiagnose(args, { account });
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.23.1",
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 (not threads) using Gmail query syntax"
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.23.1",
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>",
@@ -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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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 (page) searchArgs.push(`--page=${page}`);
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 a reply without sending use gog_gmail_drafts_create.',
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
- page: z.string().optional().describe('Page token'),
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
- return runOrDiagnose(args, { account });
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
- page: 'tok',
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', '--page=tok', '--all', '--include-body', '--full', '--body-format=html', '--include-attachments=false', '--use-indexed-attachment-ids=false'],
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' });