gogcli-mcp-gmail 2.23.0 → 2.23.2

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/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
  }
@@ -31830,22 +31874,130 @@ function authToolsFor(defaultServices) {
31830
31874
  return (server) => registerAuthToolsWith(server, defaultServices);
31831
31875
  }
31832
31876
 
31877
+ // ../gogcli-mcp/src/gmail-results.ts
31878
+ function sortKey(item) {
31879
+ for (const raw of [item.internalDateIso, item.date]) {
31880
+ if (typeof raw !== "string" || !raw) continue;
31881
+ const t = Date.parse(raw);
31882
+ if (!Number.isNaN(t)) return t;
31883
+ }
31884
+ return Number.NEGATIVE_INFINITY;
31885
+ }
31886
+ function sortNewestFirst(items) {
31887
+ return [...items].sort((a, b) => {
31888
+ const ka = sortKey(a);
31889
+ const kb = sortKey(b);
31890
+ return ka === kb ? 0 : kb - ka;
31891
+ });
31892
+ }
31893
+ var COUNT_PROBE_PAGE_SIZE = 500;
31894
+ async function countMatches(method, itemsKey, query, account) {
31895
+ try {
31896
+ const params = JSON.stringify({
31897
+ userId: "me",
31898
+ q: query,
31899
+ maxResults: COUNT_PROBE_PAGE_SIZE,
31900
+ fields: `${itemsKey}/id,nextPageToken`
31901
+ });
31902
+ const raw = await run(["api", "call", "gmail", "v1", method, `--params=${params}`], { account });
31903
+ const parsed = JSON.parse(raw);
31904
+ const items = parsed[itemsKey];
31905
+ if (!Array.isArray(items)) return {};
31906
+ const more = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "";
31907
+ return more ? { atLeast: items.length } : { total: items.length };
31908
+ } catch {
31909
+ return {};
31910
+ }
31911
+ }
31912
+ async function finalizeGmailSearch(result, options) {
31913
+ const { itemsKey, method, query, account, queryIsExact = true } = options;
31914
+ const first = result.content[0];
31915
+ if (result.isError || first?.type !== "text") return result;
31916
+ let parsed;
31917
+ try {
31918
+ parsed = JSON.parse(first.text);
31919
+ } catch {
31920
+ return result;
31921
+ }
31922
+ const items = parsed[itemsKey];
31923
+ if (!Array.isArray(items)) return result;
31924
+ const sorted = sortNewestFirst(items);
31925
+ const out = { ...parsed, [itemsKey]: sorted };
31926
+ if (hasMorePages(parsed)) {
31927
+ const count = queryIsExact ? await countMatches(method, itemsKey, query, account) : {};
31928
+ annotateTruncation(out, sorted.length, count);
31929
+ }
31930
+ return rawTextResult(JSON.stringify(out));
31931
+ }
31932
+ async function fetchGmailPages(runPage, itemsKey, maxPages, startToken) {
31933
+ const merged = [];
31934
+ let base;
31935
+ let token = startToken;
31936
+ for (let pages = 0; pages < maxPages; pages++) {
31937
+ const result = await runPage(token);
31938
+ const parsed = parsePage(result, itemsKey);
31939
+ if (parsed === void 0) {
31940
+ return base === void 0 ? result : finish(base, itemsKey, merged, token);
31941
+ }
31942
+ base = parsed;
31943
+ merged.push(...parsed[itemsKey]);
31944
+ token = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "" ? parsed.nextPageToken : void 0;
31945
+ if (token === void 0) break;
31946
+ }
31947
+ return finish(base, itemsKey, merged, token);
31948
+ }
31949
+ function parsePage(result, itemsKey) {
31950
+ const first = result.content[0];
31951
+ if (result.isError || first?.type !== "text") return void 0;
31952
+ let parsed;
31953
+ try {
31954
+ parsed = JSON.parse(first.text);
31955
+ } catch {
31956
+ return void 0;
31957
+ }
31958
+ if (parsed === null || typeof parsed !== "object") return void 0;
31959
+ const obj = parsed;
31960
+ return Array.isArray(obj[itemsKey]) ? obj : void 0;
31961
+ }
31962
+ function finish(base, itemsKey, merged, token) {
31963
+ const out = { ...base, [itemsKey]: merged };
31964
+ if (token === void 0) delete out.nextPageToken;
31965
+ else out.nextPageToken = token;
31966
+ return rawTextResult(JSON.stringify(out));
31967
+ }
31968
+
31833
31969
  // ../gogcli-mcp/src/tools/gmail.ts
31834
31970
  function registerGmailTools(server) {
31835
31971
  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).`,
31972
+ 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
31973
  annotations: { readOnlyHint: true },
31838
31974
  inputSchema: {
31839
31975
  query: external_exports.string().describe("Gmail search query"),
31840
31976
  max: external_exports.number().int().optional().describe("Max results to return (default: 10)"),
31977
+ pageToken: pageTokenParam,
31978
+ page: pageAliasParam,
31979
+ 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.'),
31980
+ 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
31981
  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
31982
  account: accountParam
31843
31983
  }
31844
- }, async ({ query, max, fromContact, account }) => {
31984
+ }, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
31845
31985
  const args = ["gmail", "search", query];
31846
31986
  if (max !== void 0) args.push(`--max=${max}`);
31987
+ if (all) args.push("--all");
31847
31988
  if (fromContact) args.push(`--from-contact=${fromContact}`);
31848
- return runOrDiagnose(args, { account });
31989
+ const runPage = (tok) => runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
31990
+ const token = resolvePageToken({ pageToken, page });
31991
+ const result = maxPages !== void 0 ? await fetchGmailPages(runPage, "threads", maxPages, token) : await runPage(token);
31992
+ return finalizeGmailSearch(result, {
31993
+ itemsKey: "threads",
31994
+ method: "users.threads.list",
31995
+ query,
31996
+ account,
31997
+ // --from-contact is expanded INSIDE gog, against the People API, so the
31998
+ // query Gmail actually saw is not the one we hold here.
31999
+ queryIsExact: !fromContact
32000
+ });
31849
32001
  });
31850
32002
  server.registerTool("gog_gmail_get", {
31851
32003
  description: "Get a Gmail message by ID.",
@@ -31896,7 +32048,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
31896
32048
  );
31897
32049
 
31898
32050
  // ../gogcli-mcp/src/server.ts
31899
- var VERSION = true ? "2.23.0" : "0.0.0";
32051
+ var VERSION = true ? "2.23.2" : "0.0.0";
31900
32052
 
31901
32053
  // ../gogcli-mcp/src/auth-log.ts
31902
32054
  var FAILURES = /* @__PURE__ */ new Set([
@@ -33464,15 +33616,17 @@ function registerExtraGmailTools(server) {
33464
33616
  inputSchema: {
33465
33617
  since: external_exports.string().optional().describe("Start history ID"),
33466
33618
  max: external_exports.number().optional().describe("Max results (default: 100)"),
33467
- page: external_exports.string().optional().describe("Page token"),
33619
+ pageToken: pageTokenParam,
33620
+ page: pageAliasParam,
33468
33621
  all: external_exports.boolean().optional().describe("Fetch all pages"),
33469
33622
  account: accountParam
33470
33623
  }
33471
- }, async ({ since, max, page, all, account }) => {
33624
+ }, async ({ since, max, pageToken, page, all, account }) => {
33472
33625
  const args = ["gmail", "history"];
33473
33626
  if (since) args.push(`--since=${since}`);
33474
33627
  if (max !== void 0) args.push(`--max=${max}`);
33475
- if (page) args.push(`--page=${page}`);
33628
+ const token = resolvePageToken({ pageToken, page });
33629
+ if (token) args.push(`--page=${token}`);
33476
33630
  if (all) args.push("--all");
33477
33631
  return runOrDiagnose(args, { account });
33478
33632
  });
@@ -33567,7 +33721,7 @@ function registerExtraGmailTools(server) {
33567
33721
  return runOrDiagnose(args, { account });
33568
33722
  });
33569
33723
  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.",
33724
+ 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
33725
  annotations: { readOnlyHint: true },
33572
33726
  inputSchema: {
33573
33727
  threadId: external_exports.string().describe("Gmail thread ID"),
@@ -33692,17 +33846,19 @@ function registerExtraGmailTools(server) {
33692
33846
  annotations: { readOnlyHint: true },
33693
33847
  inputSchema: {
33694
33848
  max: external_exports.number().optional().describe("Max results (default: 20)"),
33695
- page: external_exports.string().optional().describe("Page token"),
33849
+ pageToken: pageTokenParam,
33850
+ page: pageAliasParam,
33696
33851
  all: external_exports.boolean().optional().describe("Fetch all pages"),
33697
33852
  enrich: external_exports.boolean().optional().describe(
33698
33853
  "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
33854
  ),
33700
33855
  account: accountParam
33701
33856
  }
33702
- }, async ({ max, page, all, enrich, account }) => {
33857
+ }, async ({ max, pageToken, page, all, enrich, account }) => {
33703
33858
  const args = ["gmail", "drafts", "list"];
33704
33859
  if (max !== void 0) args.push(`--max=${max}`);
33705
- if (page) args.push(`--page=${page}`);
33860
+ const token = resolvePageToken({ pageToken, page });
33861
+ if (token) args.push(`--page=${token}`);
33706
33862
  if (all) args.push("--all");
33707
33863
  const result = await runOrDiagnose(args, { account });
33708
33864
  let parsed;
@@ -33720,7 +33876,7 @@ function registerExtraGmailTools(server) {
33720
33876
  if (enrich) {
33721
33877
  const searchArgs = ["gmail", "messages", "search", "in:drafts", `--max=${max ?? GOG_DRAFTS_LIST_DEFAULT_MAX}`];
33722
33878
  if (all) searchArgs.push("--all");
33723
- if (page) searchArgs.push(`--page=${page}`);
33879
+ if (token) searchArgs.push(`--page=${token}`);
33724
33880
  searchArgs.push("--include-attachments=false", "--use-indexed-attachment-ids=false");
33725
33881
  try {
33726
33882
  const messages = JSON.parse(await runNormalized(searchArgs, { account })).messages;
@@ -33875,14 +34031,17 @@ function registerExtraGmailTools(server) {
33875
34031
  if (f.from) args.push(`--from=${f.from}`);
33876
34032
  args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
33877
34033
  }
33878
- function withRefetchNote(written, draftId) {
34034
+ function withRefetchNote(written, draftId, refetch) {
34035
+ const detail = resultText(refetch)?.trim();
34036
+ const looksForked = detail !== void 0 && DRAFT_NOT_FOUND_PATTERN.test(detail);
34037
+ const because = looksForked ? "the id did not resolve, which on this mailbox usually means the draft was forked by a mail client between the write and the read. Run gog_gmail_drafts_list to find the current id" : `the read failed for a different reason, reported verbatim here: ${detail ?? "(no detail supplied)"}. That is a failure of the READ ONLY`;
33879
34038
  return {
33880
34039
  ...written,
33881
34040
  content: [
33882
34041
  ...written.content,
33883
34042
  {
33884
34043
  type: "text",
33885
- text: `Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up read-back requested by returnFull could not be performed \u2014 the id did not resolve, which on this mailbox usually means the draft was forked by a mail client between the write and the read. Nothing was lost. Run gog_gmail_drafts_list to find the current id, or gog_gmail_drafts_get on ${draftId} to confirm.`
34044
+ text: `Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up read-back requested by returnFull could not be performed \u2014 ${because}. Nothing was lost; run gog_gmail_drafts_get on ${draftId} to confirm the saved content.`
33886
34045
  }
33887
34046
  ]
33888
34047
  };
@@ -33904,7 +34063,7 @@ function registerExtraGmailTools(server) {
33904
34063
  if (draftId) {
33905
34064
  const refetched = await runOrDiagnose(["gmail", "drafts", "get", draftId, "--use-indexed-attachment-ids=false"], { account });
33906
34065
  if (refetched.isError !== true) final = refetched;
33907
- else final = withRefetchNote(result, draftId);
34066
+ else final = withRefetchNote(result, draftId, refetched);
33908
34067
  }
33909
34068
  }
33910
34069
  if (!verification) return final;
@@ -34118,12 +34277,14 @@ function registerExtraGmailTools(server) {
34118
34277
  return runOrDiagnose(args, { account });
34119
34278
  });
34120
34279
  server.registerTool("gog_gmail_messages_search", {
34121
- description: "Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message.",
34280
+ 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.',
34122
34281
  annotations: { readOnlyHint: true },
34123
34282
  inputSchema: {
34124
34283
  query: external_exports.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
34125
34284
  max: external_exports.number().optional().describe("Max results"),
34126
- page: external_exports.string().optional().describe("Page token"),
34285
+ pageToken: pageTokenParam,
34286
+ page: pageAliasParam,
34287
+ 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.'),
34127
34288
  all: external_exports.boolean().optional().describe("Fetch all pages"),
34128
34289
  includeBody: external_exports.boolean().optional().describe("Include the decoded message body in each result"),
34129
34290
  full: external_exports.boolean().optional().describe("Show full message bodies without truncation (implies includeBody)"),
@@ -34132,17 +34293,24 @@ function registerExtraGmailTools(server) {
34132
34293
  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."),
34133
34294
  account: accountParam
34134
34295
  }
34135
- }, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
34296
+ }, async ({ query, max, pageToken, page, maxPages, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
34136
34297
  const args = ["gmail", "messages", "search", query];
34137
34298
  if (max !== void 0) args.push(`--max=${max}`);
34138
- if (page) args.push(`--page=${page}`);
34139
34299
  if (all) args.push("--all");
34140
34300
  if (includeBody) args.push("--include-body");
34141
34301
  if (full) args.push("--full");
34142
34302
  if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
34143
34303
  args.push(includeAttachments ? "--include-attachments" : "--include-attachments=false");
34144
34304
  args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
34145
- return runOrDiagnose(args, { account });
34305
+ const runPage = (tok) => runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
34306
+ const token = resolvePageToken({ pageToken, page });
34307
+ const result = maxPages !== void 0 ? await fetchGmailPages(runPage, "messages", maxPages, token) : await runPage(token);
34308
+ return finalizeGmailSearch(result, {
34309
+ itemsKey: "messages",
34310
+ method: "users.messages.list",
34311
+ query,
34312
+ account
34313
+ });
34146
34314
  });
34147
34315
  server.registerTool("gog_gmail_labels_style", {
34148
34316
  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.0",
6
+ "version": "2.23.2",
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",
@@ -235,7 +235,7 @@
235
235
  },
236
236
  {
237
237
  "name": "gog_gmail_messages_search",
238
- "description": "Search individual messages (not threads) using Gmail query syntax"
238
+ "description": "Search individual messages; newest-first, and flags a truncated result set"
239
239
  },
240
240
  {
241
241
  "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.0",
3
+ "version": "2.23.2",
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');
@@ -2905,8 +2909,22 @@ export function registerExtraGmailTools(server: McpServer): void {
2905
2909
  // the raw write result if the id can't be determined.
2906
2910
  /** The write succeeded; only the `returnFull` re-read did not. Hand back the
2907
2911
  * acknowledgement, and say plainly which half failed — a caller that cannot
2908
- * tell those apart will either re-send or delete the wrong copy. */
2909
- function withRefetchNote(written: CallToolResult, draftId: string): CallToolResult {
2912
+ * tell those apart will either re-send or delete the wrong copy.
2913
+ *
2914
+ * A missing id and any OTHER read failure are different stories and get
2915
+ * different text. Gating on DRAFT_NOT_FOUND_PATTERN — the same test
2916
+ * forkAwareDraftFailure uses — keeps the fork explanation for the case that
2917
+ * actually looks like one; a permission error, a timeout or a transport fault
2918
+ * keeps its own message instead of being retold as a fork, which would both
2919
+ * mislead and discard the only text saying what really went wrong. */
2920
+ function withRefetchNote(written: CallToolResult, draftId: string, refetch: CallToolResult): CallToolResult {
2921
+ const detail = resultText(refetch)?.trim();
2922
+ const looksForked = detail !== undefined && DRAFT_NOT_FOUND_PATTERN.test(detail);
2923
+ const because = looksForked
2924
+ ? 'the id did not resolve, which on this mailbox usually means the draft was forked by a mail client ' +
2925
+ 'between the write and the read. Run gog_gmail_drafts_list to find the current id'
2926
+ : `the read failed for a different reason, reported verbatim here: ${detail ?? '(no detail supplied)'}. ` +
2927
+ 'That is a failure of the READ ONLY';
2910
2928
  return {
2911
2929
  ...written,
2912
2930
  content: [
@@ -2915,10 +2933,8 @@ export function registerExtraGmailTools(server: McpServer): void {
2915
2933
  type: 'text' as const,
2916
2934
  text:
2917
2935
  `Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up ` +
2918
- 'read-back requested by returnFull could not be performed — the id did not resolve, which on ' +
2919
- 'this mailbox usually means the draft was forked by a mail client between the write and the ' +
2920
- `read. Nothing was lost. Run gog_gmail_drafts_list to find the current id, or ` +
2921
- `gog_gmail_drafts_get on ${draftId} to confirm.`,
2936
+ `read-back requested by returnFull could not be performed — ${because}. Nothing was lost; ` +
2937
+ `run gog_gmail_drafts_get on ${draftId} to confirm the saved content.`,
2922
2938
  },
2923
2939
  ],
2924
2940
  };
@@ -2969,7 +2985,7 @@ export function registerExtraGmailTools(server: McpServer): void {
2969
2985
  // most destructive thing this tool can say to someone about to tidy up
2970
2986
  // the sibling copy.
2971
2987
  if (refetched.isError !== true) final = refetched;
2972
- else final = withRefetchNote(result, draftId);
2988
+ else final = withRefetchNote(result, draftId, refetched);
2973
2989
  }
2974
2990
  }
2975
2991
  if (!verification) return final;
@@ -3266,12 +3282,17 @@ export function registerExtraGmailTools(server: McpServer): void {
3266
3282
  });
3267
3283
 
3268
3284
  server.registerTool('gog_gmail_messages_search', {
3269
- description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message.',
3285
+ description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message. '
3286
+ + 'Results are ALWAYS newest-first by Gmail\'s internalDate — the wrapper sorts them, so the first result is the most recent match. '
3287
+ + '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. '
3288
+ + 'If you already know the thread, read it with gog_gmail_thread_get instead of searching for it.',
3270
3289
  annotations: { readOnlyHint: true },
3271
3290
  inputSchema: {
3272
3291
  query: z.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
3273
3292
  max: z.number().optional().describe('Max results'),
3274
- page: z.string().optional().describe('Page token'),
3293
+ pageToken: pageTokenParam,
3294
+ page: pageAliasParam,
3295
+ 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.'),
3275
3296
  all: z.boolean().optional().describe('Fetch all pages'),
3276
3297
  includeBody: z.boolean().optional().describe('Include the decoded message body in each result'),
3277
3298
  full: z.boolean().optional().describe('Show full message bodies without truncation (implies includeBody)'),
@@ -3280,10 +3301,9 @@ export function registerExtraGmailTools(server: McpServer): void {
3280
3301
  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.'),
3281
3302
  account: accountParam,
3282
3303
  },
3283
- }, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
3304
+ }, async ({ query, max, pageToken, page, maxPages, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
3284
3305
  const args = ['gmail', 'messages', 'search', query];
3285
3306
  if (max !== undefined) args.push(`--max=${max}`);
3286
- if (page) args.push(`--page=${page}`);
3287
3307
  if (all) args.push('--all');
3288
3308
  if (includeBody) args.push('--include-body');
3289
3309
  if (full) args.push('--full');
@@ -3293,7 +3313,19 @@ export function registerExtraGmailTools(server: McpServer): void {
3293
3313
  // nothing in the arg array to show for it. See gog_gmail_thread_get.
3294
3314
  args.push(includeAttachments ? '--include-attachments' : '--include-attachments=false');
3295
3315
  args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
3296
- return runOrDiagnose(args, { account });
3316
+ // See gog_gmail_search: the cursor rides per page so the walk can advance it.
3317
+ const runPage = (tok: string | undefined) =>
3318
+ runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
3319
+ const token = resolvePageToken({ pageToken, page });
3320
+ const result = maxPages !== undefined
3321
+ ? await fetchGmailPages(runPage, 'messages', maxPages, token)
3322
+ : await runPage(token);
3323
+ return finalizeGmailSearch(result, {
3324
+ itemsKey: 'messages',
3325
+ method: 'users.messages.list',
3326
+ query,
3327
+ account,
3328
+ });
3297
3329
  });
3298
3330
 
3299
3331
  server.registerTool('gog_gmail_labels_style', {
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { registerExtraGmailTools } from '../../src/tools/gmail-extra.js';
3
+ import * as lib from '../../../gogcli-mcp/src/lib.js';
4
+ import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
5
+ import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
6
+
7
+ vi.mock('../../../gogcli-mcp/src/lib.js', async (o) => ({ ...(await o<typeof lib>()), run: vi.fn(), runOrDiagnose: vi.fn(), diagnose: vi.fn() }));
8
+
9
+ let harness: TestHarness;
10
+ beforeEach(async () => {
11
+ vi.clearAllMocks();
12
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
13
+ vi.mocked(lib.diagnose).mockResolvedValue(errorResult('diagnosed'));
14
+ harness = await createTestHarness(registerExtraGmailTools);
15
+ });
16
+
17
+ const b64 = (t: string) => Buffer.from(t, 'utf8').toString('base64url');
18
+ const draft = (id: string, body: string) => JSON.stringify({
19
+ draft: {
20
+ id,
21
+ message: { id: `m${id}`, threadId: `t${id}`, payload: { mimeType: 'text/plain', headers: [{ name: 'Subject', value: 'S' }], body: { data: b64(body) } } },
22
+ },
23
+ });
24
+
25
+ /**
26
+ * #264: bodyLineCount counted RAW normalized lines while diffBodyLines compares
27
+ * Set members, so onlyInACount + sharedLineCount === bodyLineCount stopped
28
+ * holding the moment a body repeated a line. Dividers repeat constantly in real
29
+ * mail, so this is the ordinary case, not an edge one.
30
+ *
31
+ * Asserted through the TOOL, against the emitted payload, because
32
+ * describeDraftSide is module-private — and because the payload is what a
33
+ * caller actually does arithmetic on.
34
+ */
35
+ const A = ['Intro paragraph.', '---', 'Middle paragraph.', '---', 'Closing paragraph.'].join('\n');
36
+ const B = ['Intro paragraph.', '---', 'A different middle.', '---', 'Closing paragraph.'].join('\n');
37
+
38
+ describe('gog_gmail_drafts_diff count arithmetic', () => {
39
+ it('onlyInACount + sharedLineCount === bodyLineCount when a body repeats a line', async () => {
40
+ vi.mocked(lib.run).mockImplementation(async (args: readonly string[]) =>
41
+ draft(String(args[3]), String(args[3]) === 'a1' ? A : B));
42
+
43
+ const res = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'a1', draftIdB: 'b1' });
44
+ const raw = res.content.map((c: any) => c.text).join('\n');
45
+ const payload = JSON.parse(raw);
46
+
47
+ const sideA = payload.drafts.a;
48
+ const diff = payload.bodyDiff;
49
+
50
+ expect(sideA.bodyLineCount).toBeDefined();
51
+ expect(diff.onlyInACount).toBeDefined();
52
+ expect(diff.sharedLineCount).toBeDefined();
53
+ // The body has 5 raw lines but only 4 distinct ones — that gap is the bug.
54
+ expect(sideA.bodyLineCount).toBe(4);
55
+ expect(diff.onlyInACount + diff.sharedLineCount).toBe(sideA.bodyLineCount);
56
+ });
57
+ });
@@ -53,6 +53,43 @@ describe('a failed returnFull re-read does not erase a successful write', () =>
53
53
  expect(res.content.map((c: any) => c.text).join('\n')).toContain('payload');
54
54
  });
55
55
 
56
+ it('does NOT retell a non-404 read failure as a fork, and keeps its text', async () => {
57
+ // #264: any refetch failure used to get the fork explanation, discarding the
58
+ // only text saying what actually went wrong. A permission error is not a fork.
59
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: readonly string[]) =>
60
+ args[2] === 'update' ? rawTextResult(ack) : errorResult('Google API error (403 forbidden): insufficient permission'));
61
+
62
+ const res = await harness.callTool('gog_gmail_drafts_update', { draftId: 'r123', subject: 'S', body: 'x', returnFull: true });
63
+ const text = res.content.map((c: any) => c.text).join('\n');
64
+
65
+ expect(res.isError).not.toBe(true);
66
+ expect(text).toContain('SUCCEEDED');
67
+ expect(text).toContain('403 forbidden'); // the real cause survives
68
+ expect(text).not.toMatch(/forked by a mail client/); // and is not retold as a fork
69
+ });
70
+
71
+ it('still gives the fork explanation on a genuine 404', async () => {
72
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: readonly string[]) =>
73
+ args[2] === 'update' ? rawTextResult(ack) : errorResult('Google API error (404 notFound)'));
74
+ const res = await harness.callTool('gog_gmail_drafts_update', { draftId: 'r123', subject: 'S', body: 'x', returnFull: true });
75
+ expect(res.content.map((c: any) => c.text).join('\n')).toMatch(/forked by a mail client/);
76
+ });
77
+
78
+ it('still says the write succeeded when the read failure carries no text', async () => {
79
+ // An error result with no content at all: the note must not interpolate
80
+ // `undefined` into the sentence a caller reads to decide what to do next.
81
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: readonly string[]) =>
82
+ args[2] === 'update' ? rawTextResult(ack) : ({ isError: true, content: [] } as any));
83
+
84
+ const res = await harness.callTool('gog_gmail_drafts_update', { draftId: 'r123', subject: 'S', body: 'x', returnFull: true });
85
+ const text = res.content.map((c: any) => c.text).join('\n');
86
+
87
+ expect(res.isError).not.toBe(true);
88
+ expect(text).toContain('SUCCEEDED');
89
+ expect(text).toContain('(no detail supplied)');
90
+ expect(text).not.toContain('undefined');
91
+ });
92
+
56
93
  it('still reports a genuinely failed WRITE as an error', async () => {
57
94
  // The guard must not swallow a real failure: here the update itself fails.
58
95
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(errorResult('Google API error (404 notFound)'));
@@ -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
 
@@ -1735,7 +1749,7 @@ describe('gog_gmail_messages_search', () => {
1735
1749
  await harness.callTool('gog_gmail_messages_search', {
1736
1750
  query: 'is:unread',
1737
1751
  max: 10,
1738
- page: 'tok',
1752
+ pageToken: 'tok',
1739
1753
  all: true,
1740
1754
  includeBody: true,
1741
1755
  full: true,
@@ -1743,7 +1757,7 @@ describe('gog_gmail_messages_search', () => {
1743
1757
  account: 'me@x.com',
1744
1758
  });
1745
1759
  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'],
1760
+ ['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
1761
  { account: 'me@x.com' },
1748
1762
  );
1749
1763
  });
@@ -1757,6 +1771,98 @@ describe('gog_gmail_messages_search', () => {
1757
1771
  });
1758
1772
  });
1759
1773
 
1774
+ describe('page-cursor contract', () => {
1775
+ it('never steers a caller to the deprecated `page` alias', async () => {
1776
+ const { tools } = await harness.client.listTools();
1777
+ const offenders = (tools as { name: string; description?: string }[])
1778
+ .filter((t) => /`page`|\bas page\b/i.test(t.description ?? ''))
1779
+ .map((t) => t.name);
1780
+ expect(offenders).toEqual([]);
1781
+ });
1782
+
1783
+ it('offers the alias wherever pageToken is accepted', async () => {
1784
+ const { tools } = await harness.client.listTools();
1785
+ const withCursor = (tools as { name: string; inputSchema?: { properties?: Record<string, unknown> } }[])
1786
+ .filter((t) => 'pageToken' in (t.inputSchema?.properties ?? {}));
1787
+ expect(withCursor.length).toBeGreaterThan(0);
1788
+ expect(withCursor.filter((t) => !('page' in (t.inputSchema?.properties ?? {}))).map((t) => t.name)).toEqual([]);
1789
+ });
1790
+ });
1791
+
1792
+ describe('gog_gmail_messages_search — the page cursor reaches the API', () => {
1793
+ it('threads a pageToken through to the gog invocation', async () => {
1794
+ await harness.callTool('gog_gmail_messages_search', { query: 'x', pageToken: 'CURSOR' });
1795
+ const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0] as string[];
1796
+ expect(args).toContain('--page=CURSOR');
1797
+ });
1798
+
1799
+ it('still accepts the deprecated page alias, and pageToken wins over it', async () => {
1800
+ await harness.callTool('gog_gmail_messages_search', { query: 'x', page: 'OLD' });
1801
+ expect(vi.mocked(lib.runOrDiagnose).mock.calls[0][0]).toContain('--page=OLD');
1802
+ vi.mocked(lib.runOrDiagnose).mockClear();
1803
+ await harness.callTool('gog_gmail_messages_search', { query: 'x', pageToken: 'NEW', page: 'OLD' });
1804
+ expect(vi.mocked(lib.runOrDiagnose).mock.calls[0][0]).toContain('--page=NEW');
1805
+ });
1806
+
1807
+ it('walks and merges pages under maxPages', async () => {
1808
+ vi.mocked(lib.runOrDiagnose)
1809
+ .mockResolvedValueOnce(rawTextResult(JSON.stringify({ messages: [{ id: 'a' }], nextPageToken: 'T1' })))
1810
+ .mockResolvedValueOnce(rawTextResult(JSON.stringify({ messages: [{ id: 'b' }] })));
1811
+ const result = await harness.callTool('gog_gmail_messages_search', { query: 'x', maxPages: 4 });
1812
+ const out = JSON.parse(result.content[0].text as string);
1813
+ expect(out.messages.map((m: { id: string }) => m.id)).toEqual(['a', 'b']);
1814
+ expect(out).not.toHaveProperty('nextPageToken');
1815
+ expect(vi.mocked(lib.runOrDiagnose).mock.calls[1][0]).toContain('--page=T1');
1816
+ });
1817
+ });
1818
+
1819
+ describe('gog_gmail_messages_search — result finalization', () => {
1820
+ it('sorts results newest-first', async () => {
1821
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult(JSON.stringify({
1822
+ messages: [
1823
+ { id: 'old', internalDateIso: '2026-08-01T09:00:00-04:00' },
1824
+ { id: 'new', internalDateIso: '2026-08-12T12:36:00-04:00' },
1825
+ { id: 'mid', internalDateIso: '2026-08-05T09:00:00-04:00' },
1826
+ ],
1827
+ nextPageToken: '',
1828
+ })));
1829
+ const result = await harness.callTool('gog_gmail_messages_search', { query: 'x' });
1830
+ const out = JSON.parse(result.content[0].text as string);
1831
+ expect(out.messages.map((m: { id: string }) => m.id)).toEqual(['new', 'mid', 'old']);
1832
+ expect(out).not.toHaveProperty('truncated');
1833
+ });
1834
+
1835
+ it('marks a capped result set truncated and counts the real total', async () => {
1836
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult(JSON.stringify({
1837
+ messages: [{ id: 'a' }, { id: 'b' }],
1838
+ nextPageToken: 'tok',
1839
+ })));
1840
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({
1841
+ messages: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }],
1842
+ }));
1843
+ const result = await harness.callTool('gog_gmail_messages_search', { query: 'x', max: 2 });
1844
+ const out = JSON.parse(result.content[0].text as string);
1845
+ expect(out.truncated).toBe(true);
1846
+ expect(out.returned).toBe(2);
1847
+ expect(out.totalMatches).toBe(5);
1848
+ expect(out.warning).toBe(
1849
+ 'INCOMPLETE RESULT SET: returned 2 of 5 matches. Do not report an absence of results ' +
1850
+ 'based on this response. Page with nextPageToken or narrow the query.',
1851
+ );
1852
+ expect(runner.run).toHaveBeenCalledWith(
1853
+ ['api', 'call', 'gmail', 'v1', 'users.messages.list',
1854
+ '--params={"userId":"me","q":"x","maxResults":500,"fields":"messages/id,nextPageToken"}'],
1855
+ { account: undefined },
1856
+ );
1857
+ });
1858
+
1859
+ it('leaves output it does not recognise untouched', async () => {
1860
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('No results'));
1861
+ const result = await harness.callTool('gog_gmail_messages_search', { query: 'x' });
1862
+ expect(result.content[0].text).toBe('No results');
1863
+ });
1864
+ });
1865
+
1760
1866
  describe('gog_gmail_labels_style', () => {
1761
1867
  it('calls runOrDiagnose with just the label', async () => {
1762
1868
  await harness.callTool('gog_gmail_labels_style', { labelIdOrName: 'Work' });