mcp-google-multi 6.0.0-alpha.73 → 6.0.0-alpha.74

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/discover.js CHANGED
@@ -96,9 +96,8 @@ export function registerDiscoverTools(registry, policy) {
96
96
  service,
97
97
  operations,
98
98
  writeControl: rendered?.hint ?? describePolicy(policy),
99
- next: operations.length > 0
100
- ? 'Call the chosen tool by name; it is now listed and callable.'
101
- : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
99
+ // catalog() falls back to a full one-line listing when nothing matches, so this is never empty.
100
+ next: 'Call the chosen tool by its exact name. A client that loads tools on demand may need its own tool-search or load step before the call succeeds.',
102
101
  }),
103
102
  },
104
103
  ],
@@ -58,7 +58,10 @@ export type HintCase = {
58
58
  entries: string[];
59
59
  } | {
60
60
  kind: 'no_default_account';
61
- aliases: string[];
61
+ accounts: {
62
+ alias: string;
63
+ email: string;
64
+ }[];
62
65
  } | {
63
66
  kind: 'no_accounts';
64
67
  } | {
package/dist/oauth-as.js CHANGED
@@ -425,7 +425,7 @@ export function buildAuthServer(config, deps = {}) {
425
425
  res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
426
426
  `<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
427
427
  `<h2>Authorize access?</h2>` +
428
- `<p>A newly-registered application (<code>${escapeHtml(clientId)}</code>) is requesting access to your Google Workspace through this server.</p>` +
428
+ `<p>An application (<code>${escapeHtml(clientId)}</code>) asks to connect to this server as you.</p>` +
429
429
  `<p>If approved, it will receive an authorization redirected to:<br><code>${escapeHtml(redirectUri)}</code></p>` +
430
430
  `<p><strong>If you did not start this, close this window.</strong></p>` +
431
431
  `<form method="POST" action="/authorize"><input type="hidden" name="pending" value="${escapeHtml(pendingToken)}">` +
@@ -498,7 +498,7 @@ export function buildAuthServer(config, deps = {}) {
498
498
  }
499
499
  log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
500
500
  res.writeHead(200, { 'Content-Type': 'text/html' });
501
- res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
501
+ res.end(`<!doctype html><meta charset=utf-8><p>Linked "${escapeHtml(st.alias)}" to this server. Start a new chat to use it.</p>`);
502
502
  return true;
503
503
  }
504
504
  if (st.flow === 'alias_reauth') {
@@ -38,6 +38,8 @@ export interface CatalogOperation {
38
38
  * real scope error rather than a bare "not found". */
39
39
  unreachable?: true;
40
40
  }
41
+ /** The leading sentence of a (possibly multi-sentence) tool description. */
42
+ export declare function firstSentence(text: string): string;
41
43
  /** The service a tool name belongs to: its prefix, unless overridden. */
42
44
  export declare function serviceOf(name: string): string;
43
45
  export declare function inferCud(name: string): Cud;
package/dist/registry.js CHANGED
@@ -23,6 +23,37 @@ export function resolveDiscoveryMode(env = process.env) {
23
23
  }
24
24
  return DISCOVERY_MODES.includes(raw) ? raw : 'lazy';
25
25
  }
26
+ /** The leading sentence of a (possibly multi-sentence) tool description. */
27
+ export function firstSentence(text) {
28
+ let openParens = 0;
29
+ let i = 0;
30
+ const abbrevs = /^(e\.g|i\.e|etc|vs)\.$/i;
31
+ while (i < text.length) {
32
+ const char = text[i];
33
+ if (char === '(') {
34
+ openParens++;
35
+ }
36
+ else if (char === ')') {
37
+ openParens = Math.max(0, openParens - 1);
38
+ }
39
+ else if (char === '.' && i + 1 < text.length && text[i + 1] === ' ' && openParens === 0) {
40
+ // Found a period-space outside parentheses
41
+ // Check if it's preceded by an abbreviation (e.g., i.e., etc, vs)
42
+ // Find the word before this period
43
+ let wordStart = i - 1;
44
+ while (wordStart > 0 && /[a-zA-Z.]/.test(text[wordStart - 1])) {
45
+ wordStart--;
46
+ }
47
+ const word = text.slice(wordStart, i + 1);
48
+ if (!abbrevs.test(word)) {
49
+ // Not an abbreviation, so this is a valid sentence boundary
50
+ return text.slice(0, i + 1);
51
+ }
52
+ }
53
+ i++;
54
+ }
55
+ return text;
56
+ }
26
57
  const CUD_OVERRIDES = {
27
58
  drive_untrash: 'update',
28
59
  drive_transfer: 'create',
@@ -196,8 +227,10 @@ export class ToolRegistry {
196
227
  refreshAccountSetIfStale();
197
228
  const def = this.accounts().defaultAccount;
198
229
  if (!def) {
199
- const aliases = this.accounts().aliases;
200
- const rendered = hints()?.render({ kind: 'no_default_account', aliases });
230
+ const accountSet = this.accounts();
231
+ const accounts = accountSet.aliases.map((alias) => ({ alias, email: accountSet.configs[alias]?.email ?? '' }));
232
+ const rendered = hints()?.render({ kind: 'no_default_account', accounts });
233
+ const pairs = accounts.map((a) => `${a.alias}=${a.email}`).join(', ');
201
234
  return {
202
235
  content: [
203
236
  {
@@ -205,7 +238,7 @@ export class ToolRegistry {
205
238
  text: JSON.stringify({
206
239
  error: 'E_NO_DEFAULT_ACCOUNT',
207
240
  message: 'No "account" given and no default account is configured.',
208
- hint: rendered?.hint ?? `Pass account explicitly (valid: ${aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
241
+ hint: rendered?.hint ?? `Pass account explicitly (valid: ${pairs}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
209
242
  retriable: false,
210
243
  }),
211
244
  },
@@ -313,17 +346,31 @@ export class ToolRegistry {
313
346
  .map(([key, e]) => ({ key, tools: e.tools }));
314
347
  }
315
348
  catalog(service, query) {
316
- const q = query?.trim().toLowerCase();
317
- return this.tools
318
- .filter((t) => !t.meta && t.service === service)
319
- .filter((t) => !q || t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q))
320
- .map((t) => ({
349
+ const candidates = this.tools.filter((t) => !t.meta && t.service === service);
350
+ const toOp = (t, summary = t.description) => ({
321
351
  tool: t.name,
322
- summary: t.description,
352
+ summary,
323
353
  args: Object.keys(t.inputShape),
324
354
  cud: t.cud,
325
355
  ...(this.isUngrantable(t) ? { unreachable: true } : {}),
326
- }));
356
+ });
357
+ const q = query?.trim().toLowerCase();
358
+ if (!q)
359
+ return candidates.map((t) => toOp(t));
360
+ // Tokenized OR-match (a strict superset of a plain substring test: a full-string
361
+ // hit implies every one of its tokens also hits) ranked by how many tokens matched.
362
+ const ranked = candidates
363
+ .map((t) => {
364
+ const hay = `${t.name} ${t.description}`.toLowerCase();
365
+ return { t, matched: q.split(/\s+/).filter(Boolean).filter((tok) => hay.includes(tok)).length };
366
+ })
367
+ .filter((r) => r.matched > 0)
368
+ .sort((a, b) => b.matched - a.matched);
369
+ if (ranked.length > 0)
370
+ return ranked.map((r) => toOp(r.t));
371
+ // Nothing matched: list the whole service so the agent can still browse,
372
+ // one-line summaries only so a wide miss doesn't blow the response budget.
373
+ return candidates.map((t) => toOp(t, firstSentence(t.description)));
327
374
  }
328
375
  /** The metrics recorder, for hook sites (escape hatch); null when off. */
329
376
  get usageMetrics() {
@@ -36,4 +36,11 @@ export interface AccountHealth {
36
36
  scopes: ScopesReport;
37
37
  }
38
38
  export declare function deriveAccountHealth(alias: string, deps?: AccountHealthDeps): AccountHealth;
39
+ export interface AccountSummary {
40
+ alias: string;
41
+ email: string;
42
+ admin: boolean;
43
+ tokenStatus: TokenStatus;
44
+ default: boolean;
45
+ }
39
46
  export declare function registerAccountTools(registry: ToolRegistry, deps?: AccountHealthDeps): void;
@@ -5,6 +5,7 @@ import { buildScopesReport } from '../scope-observability.js';
5
5
  import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
6
6
  import { hasToken, readToken } from '../token-store.js';
7
7
  import { hints } from '../hint-provider.js';
8
+ import { coerceBoolean } from './_coerce.js';
8
9
  /** Health deps over one context's token store (and, optionally, its account
9
10
  * view); the module-level functions are the single owner's store. */
10
11
  export function accountHealthDepsFor(store, accounts) {
@@ -91,18 +92,28 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
91
92
  scopes: report,
92
93
  };
93
94
  }
95
+ function summarizeAccountHealth(health, isDefault) {
96
+ return {
97
+ alias: health.alias,
98
+ email: health.email,
99
+ admin: health.admin,
100
+ tokenStatus: health.token.status,
101
+ default: isDefault,
102
+ };
103
+ }
94
104
  export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
95
105
  const registerMeta = registry.registerMeta;
96
106
  registerMeta('account_list', {
97
- description: 'List the configured Google accounts: alias, email, admin flag, token health ' +
98
- '(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and granted vs configured scopes. ' +
99
- 'Use this to see which account aliases are available and healthy.',
100
- inputSchema: {},
107
+ description: 'List the configured Google accounts: alias, email, admin flag, token status ' +
108
+ '(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and which one is the default. ' +
109
+ 'Pass verbose: true for the full per-account report (granted vs configured scopes).',
110
+ inputSchema: { verbose: coerceBoolean.optional().describe('Return the full scope report instead of the terse default') },
101
111
  _meta: { 'anthropic/alwaysLoad': true },
102
- }, async () => {
112
+ }, async ({ verbose }) => {
103
113
  refreshAccountSetIfStale();
104
114
  const set = registry.accountSet();
105
115
  const health = { ...deps, accounts: () => set };
116
+ const reports = set.aliases.map((alias) => deriveAccountHealth(alias, health));
106
117
  return {
107
118
  content: [
108
119
  {
@@ -110,7 +121,7 @@ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
110
121
  text: JSON.stringify({
111
122
  defaultAccount: set.defaultAccount ?? null,
112
123
  defaultAccountSource: set.defaultAccountSource ?? null,
113
- accounts: set.aliases.map((alias) => deriveAccountHealth(alias, health)),
124
+ accounts: verbose ? reports : reports.map((r) => summarizeAccountHealth(r, r.alias === set.defaultAccount)),
114
125
  }),
115
126
  },
116
127
  ],
@@ -18,20 +18,6 @@ export function registerGmailGeneratedTools(registry, deps = {}) {
18
18
  ["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.metadata", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.readonly"],
19
19
  ["https://mail.google.com/"],
20
20
  ];
21
- registerGeneratedTool(registry, {
22
- name: "gmail_users_drafts_delete",
23
- cud: "delete",
24
- description: "Immediately and permanently deletes the specified draft. Does not simply trash it. For more information, see [Create and send draft emails](https://developers.g",
25
- method: { id: "gmail.users.drafts.delete", httpMethod: "DELETE", path: "gmail/v1/users/{userId}/drafts/{id}", baseUrl: "https://gmail.googleapis.com/", requiredParams: ["userId", "id"], scopes: S_gmail_v1[0] },
26
- params: [{ "field": "id", "api": "id", "location": "path" }, { "field": "userId", "api": "userId", "location": "path" }, { "field": "fields", "api": "fields", "location": "query" }],
27
- hasBody: false,
28
- shape: {
29
- account: accountField(() => registry.accountAliases()),
30
- id: z.string().min(1).describe("The ID of the draft to delete."),
31
- userId: z.string().min(1).describe("The user's email address. The special value `me` can be used to indicate the authenticated user."),
32
- fields: z.string().optional().describe('Response field mask.'),
33
- },
34
- }, deps);
35
21
  registerGeneratedTool(registry, {
36
22
  name: "gmail_users_drafts_update",
37
23
  cud: "update",
@@ -550,7 +550,6 @@ export const GENERATED_METHOD_TOOLS = {
550
550
  "forms.forms.watches.create": "forms_forms_watches_create",
551
551
  "forms.forms.watches.delete": "forms_forms_watches_delete",
552
552
  "forms.forms.watches.renew": "forms_forms_watches_renew",
553
- "gmail.users.drafts.delete": "gmail_users_drafts_delete",
554
553
  "gmail.users.drafts.update": "gmail_users_drafts_update",
555
554
  "gmail.users.labels.get": "gmail_users_labels_get",
556
555
  "gmail.users.labels.patch": "gmail_users_labels_patch",
@@ -818,6 +817,7 @@ export const CURATED_METHOD_IDS = [
818
817
  "forms.forms.setPublishSettings",
819
818
  "forms.forms.watches.list",
820
819
  "gmail.users.drafts.create",
820
+ "gmail.users.drafts.delete",
821
821
  "gmail.users.drafts.get",
822
822
  "gmail.users.drafts.list",
823
823
  "gmail.users.drafts.send",
@@ -33,7 +33,7 @@ export declare function parseMessage(msg: any, bodyCap?: number, opts?: {
33
33
  * is rethrown so the caller maps it to a whole-call isError. Returns the
34
34
  * ordered per-id entries followed by a trailing counts summary.
35
35
  */
36
- export declare function readBatch(gmail: any, account: Account, ids: string[], full: boolean, rawHtml: boolean): Promise<any[]>;
36
+ export declare function readBatch(gmail: any, account: Account, ids: string[], full: boolean, rawHtml: boolean, metaHeaders?: string[]): Promise<any[]>;
37
37
  /** Compact gmail_search row: just the pick-one-message selection signal, with
38
38
  * the snippet flattened to one bounded line. Pure. */
39
39
  export declare function compactMessageRow(m: GmailMessageHeader): {
@@ -17,6 +17,12 @@ import * as fs from 'fs';
17
17
  function getHeader(headers, name) {
18
18
  return headers?.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
19
19
  }
20
+ function pickHeaders(headers, names) {
21
+ const out = {};
22
+ for (const name of names)
23
+ out[name] = getHeader(headers, name);
24
+ return out;
25
+ }
20
26
  function collectTextParts(part, plain, html, topLevel = false) {
21
27
  if (!part)
22
28
  return;
@@ -176,6 +182,21 @@ export async function resolveReply(gmail, account, primaryEmail, replyToMessageI
176
182
  subject: deriveReplySubject(getHeader(headers, 'Subject')),
177
183
  };
178
184
  }
185
+ // Gmail's after:/before: operators take epoch seconds; Date.parse accepts both
186
+ // an ISO date and a full date-time, so one parse covers both input shapes.
187
+ function parseEpochSeconds(value) {
188
+ // A date-only value is interpreted as 00:00 UTC.
189
+ // A date-time must include Z or an offset (+HH:MM or -HH:MM) to be unambiguous.
190
+ if (value.includes('T')) {
191
+ const hasZ = value.endsWith('Z');
192
+ const hasOffset = /[+-]\d{2}:\d{2}$/.test(value);
193
+ if (!hasZ && !hasOffset) {
194
+ return null;
195
+ }
196
+ }
197
+ const ms = Date.parse(value);
198
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
199
+ }
179
200
  const BODY_CAP_CHARS = 50_000;
180
201
  // gmail_read_batch aggregate ceiling: bounds the summed serialized output so a
181
202
  // 100-message batch can't blow the context window (per-message bodies are capped at BODY_CAP_CHARS first).
@@ -331,13 +352,14 @@ export function parseMessage(msg, bodyCap, opts) {
331
352
  * is rethrown so the caller maps it to a whole-call isError. Returns the
332
353
  * ordered per-id entries followed by a trailing counts summary.
333
354
  */
334
- export async function readBatch(gmail, account, ids, full, rawHtml) {
355
+ export async function readBatch(gmail, account, ids, full, rawHtml, metaHeaders) {
335
356
  if (ids.length === 0) {
336
357
  throw new GmailComposeError('validation_error', '`ids` must be a non-empty array of 1..100 message IDs.');
337
358
  }
338
359
  if (ids.length > 100) {
339
360
  throw new GmailComposeError('validation_error', `\`ids\` accepts at most 100 message IDs per call (got ${ids.length}).`);
340
361
  }
362
+ const metadataMode = metaHeaders !== undefined && metaHeaders.length > 0;
341
363
  const cap = full ? undefined : BODY_CAP_CHARS;
342
364
  const CHUNK_SIZE = 10;
343
365
  const entries = new Array(ids.length);
@@ -347,7 +369,9 @@ export async function readBatch(gmail, account, ids, full, rawHtml) {
347
369
  const slice = ids.slice(i, i + CHUNK_SIZE);
348
370
  const settled = await Promise.all(slice.map(async (id, j) => {
349
371
  try {
350
- const res = await gmail.users.messages.get({ userId: 'me', id, format: 'full' });
372
+ const res = metadataMode
373
+ ? await gmail.users.messages.get({ userId: 'me', id, format: 'metadata', metadataHeaders: metaHeaders })
374
+ : await gmail.users.messages.get({ userId: 'me', id, format: 'full' });
351
375
  return { idx: i + j, id, ok: true, data: res.data };
352
376
  }
353
377
  catch (err) {
@@ -356,7 +380,14 @@ export async function readBatch(gmail, account, ids, full, rawHtml) {
356
380
  }));
357
381
  for (const s of settled) {
358
382
  if (s.ok) {
359
- entries[s.idx] = parseMessage(s.data, cap, { rawHtml });
383
+ entries[s.idx] = metadataMode
384
+ ? {
385
+ id: s.data.id ?? '',
386
+ threadId: s.data.threadId ?? '',
387
+ labelIds: s.data.labelIds ?? [],
388
+ headers: pickHeaders(s.data.payload?.headers, metaHeaders),
389
+ }
390
+ : parseMessage(s.data, cap, { rawHtml });
360
391
  ok++;
361
392
  }
362
393
  else {
@@ -374,10 +405,11 @@ export async function readBatch(gmail, account, ids, full, rawHtml) {
374
405
  }
375
406
  // Aggregate guard: walk in order accumulating serialized size; once the running
376
407
  // total would exceed the budget, cap this and every later success body so the batch never blows the context window.
408
+ // Metadata-mode entries carry no body, so there is nothing to cap.
377
409
  let running = 0;
378
410
  let truncatedAny = false;
379
411
  let exhausted = false;
380
- for (const e of entries) {
412
+ for (const e of metadataMode ? [] : entries) {
381
413
  if (!e || e.error) {
382
414
  running += JSON.stringify(e ?? {}).length;
383
415
  continue;
@@ -434,27 +466,50 @@ export function registerGmailTools(server, deps = {}) {
434
466
  const registerHostFileTool = localFiles ? server.registerTool : (() => undefined);
435
467
  server.registerTool('gmail_search', {
436
468
  description: 'Search messages in a Gmail account. Returns compact rows (id, from, subject, date, snippet); ' +
437
- 'pass full=true for threadId, to, labelIds and the untruncated snippet.',
469
+ 'pass full=true for threadId, to, labelIds and the untruncated snippet. ' +
470
+ 'For a recurring "what is new" sweep, prefer gmail_list_history over polling this with since.',
438
471
  inputSchema: {
439
472
  account: accountEnum.describe('Google account alias'),
440
473
  query: z.string().describe('Gmail search syntax, e.g. "from:alice is:unread"'),
441
474
  maxResults: coerceNumber(z.number().min(1).max(100)).optional()
442
475
  .describe('Max results to return (default: 20, max: 100)'),
443
476
  full: coerceBoolean.optional().describe('Return the full row shape instead of the compact default'),
477
+ since: z.string().optional()
478
+ .describe('Only messages on/after this ISO 8601 date (00:00 UTC) or date-time with Z or offset, added to the query as after:<epoch seconds>'),
479
+ until: z.string().optional()
480
+ .describe('Only messages before this ISO 8601 date (00:00 UTC) or date-time with Z or offset, added to the query as before:<epoch seconds>'),
481
+ includeSpamTrash: coerceBoolean.optional()
482
+ .describe('Include Spam and Trash in the search. Gmail excludes both by default, even for in:anywhere.'),
444
483
  },
445
- }, async ({ account, query, maxResults, full }) => {
484
+ }, async ({ account, query, maxResults, full, since, until, includeSpamTrash }) => {
446
485
  try {
447
486
  // Gmail treats a blank `q` as no filter, so a caller passing an unset
448
487
  // variable got the whole mailbox back as if it had matched.
449
488
  if (query.trim() === '') {
450
489
  return invalidParams(account, '`query` is empty, and Gmail reads an empty query as "match everything".', 'Pass a real Gmail search term such as "is:unread" or "from:someone@example.com". Returning the whole mailbox for a blank query would look like a successful search.');
451
490
  }
491
+ let effectiveQuery = query;
492
+ if (since !== undefined) {
493
+ const epoch = parseEpochSeconds(since);
494
+ if (epoch === null) {
495
+ return invalidParams(account, `\`since\` is not a parsable ISO 8601 date or date-time: "${since}".`, 'Pass an ISO 8601 date (e.g. "2026-01-01") or a date-time with Z or offset (e.g. "2026-01-01T09:00:00Z").');
496
+ }
497
+ effectiveQuery = `${effectiveQuery} after:${epoch}`;
498
+ }
499
+ if (until !== undefined) {
500
+ const epoch = parseEpochSeconds(until);
501
+ if (epoch === null) {
502
+ return invalidParams(account, `\`until\` is not a parsable ISO 8601 date or date-time: "${until}".`, 'Pass an ISO 8601 date (e.g. "2026-01-01") or a date-time with Z or offset (e.g. "2026-01-01T09:00:00Z").');
503
+ }
504
+ effectiveQuery = `${effectiveQuery} before:${epoch}`;
505
+ }
452
506
  const auth = await getClientFn(account);
453
507
  const gmail = gmailClient({ version: 'v1', auth });
454
508
  const listRes = await gmail.users.messages.list({
455
509
  userId: 'me',
456
- q: query,
510
+ q: effectiveQuery,
457
511
  maxResults: maxResults ?? 20,
512
+ ...(includeSpamTrash !== undefined ? { includeSpamTrash } : {}),
458
513
  });
459
514
  const messages = listRes.data.messages ?? [];
460
515
  const results = [];
@@ -495,18 +550,38 @@ export function registerGmailTools(server, deps = {}) {
495
550
  });
496
551
  server.registerTool('gmail_read', {
497
552
  _meta: { 'anthropic/maxResultSizeChars': 50_000 },
498
- description: 'Read a full Gmail message by ID (body capped at 50k chars unless full=true)',
553
+ description: 'Read a full Gmail message by ID (body capped at 50k chars unless full=true). ' +
554
+ 'Pass `headers` for metadata mode: no body decoding, just id, threadId, labelIds and the named headers.',
499
555
  inputSchema: {
500
556
  account: accountEnum.describe('Google account alias'),
501
557
  messageId: z.string().min(1).describe('Gmail message ID'),
502
558
  full: coerceBoolean.optional().describe('Return the entire body without the character cap'),
503
559
  rawHtml: coerceBoolean.optional()
504
560
  .describe('Return the HTML body unconverted instead of the plain-text rendering (HTML-only messages)'),
561
+ headers: coerceArray(z.string()).optional()
562
+ .describe('Header names to fetch in metadata mode instead of the full body (e.g. ["Subject", "From"])'),
505
563
  },
506
- }, async ({ account, messageId, full, rawHtml }) => {
564
+ }, async ({ account, messageId, full, rawHtml, headers }) => {
507
565
  try {
508
566
  const auth = await getClientFn(account);
509
567
  const gmail = gmailClient({ version: 'v1', auth });
568
+ if (headers !== undefined && headers.length > 0) {
569
+ const metaRes = await gmail.users.messages.get({
570
+ userId: 'me',
571
+ id: messageId,
572
+ format: 'metadata',
573
+ metadataHeaders: headers,
574
+ });
575
+ const metaResult = {
576
+ id: metaRes.data.id ?? '',
577
+ threadId: metaRes.data.threadId ?? '',
578
+ labelIds: metaRes.data.labelIds ?? [],
579
+ headers: pickHeaders(metaRes.data.payload?.headers, headers),
580
+ };
581
+ return {
582
+ content: [{ type: 'text', text: JSON.stringify(metaResult, null, 2) }],
583
+ };
584
+ }
510
585
  const res = await gmail.users.messages.get({
511
586
  userId: 'me',
512
587
  id: messageId,
@@ -576,19 +651,21 @@ export function registerGmailTools(server, deps = {}) {
576
651
  server.registerTool('gmail_read_batch', {
577
652
  _meta: { 'anthropic/maxResultSizeChars': BATCH_MAX_RESULT_CHARS },
578
653
  annotations: { readOnlyHint: true, openWorldHint: true },
579
- description: 'Read many Gmail messages by ID in one call, collapsing the search→read triage loop. Bodies are capped at 50k chars each (unless full=true) and the aggregate output is bounded. Returns one ordered entry per id plus a trailing counts summary; a single failed id does NOT fail the batch.',
654
+ description: 'Read many Gmail messages by ID in one call, collapsing the search→read triage loop. Bodies are capped at 50k chars each (unless full=true) and the aggregate output is bounded. Returns one ordered entry per id plus a trailing counts summary; a single failed id does NOT fail the batch. Pass `headers` for metadata mode: no body decoding, just id, threadId, labelIds and the named headers per entry.',
580
655
  inputSchema: {
581
656
  account: accountEnum.describe('Google account alias'),
582
657
  ids: coerceArray(z.string()).describe('Gmail message IDs to read (1..100). Comma-separated string or JSON array.'),
583
658
  full: coerceBoolean.optional().describe('Return entire bodies without the per-message 50k character cap'),
584
659
  rawHtml: coerceBoolean.optional()
585
660
  .describe('Return HTML bodies unconverted instead of the plain-text rendering (HTML-only messages)'),
661
+ headers: coerceArray(z.string()).optional()
662
+ .describe('Header names to fetch in metadata mode instead of full bodies (e.g. ["Subject", "From"])'),
586
663
  },
587
- }, async ({ account, ids, full, rawHtml }) => {
664
+ }, async ({ account, ids, full, rawHtml, headers }) => {
588
665
  try {
589
666
  const auth = await getClientFn(account);
590
667
  const gmail = gmailClient({ version: 'v1', auth });
591
- const result = await readBatch(gmail, account, ids ?? [], full === true, rawHtml === true);
668
+ const result = await readBatch(gmail, account, ids ?? [], full === true, rawHtml === true, headers);
592
669
  return {
593
670
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
594
671
  };
@@ -993,6 +1070,26 @@ export function registerGmailTools(server, deps = {}) {
993
1070
  return mapped ?? handleGmailError(error, account);
994
1071
  }
995
1072
  });
1073
+ server.registerTool('gmail_delete_draft', {
1074
+ description: 'Permanently delete a Gmail draft by its draft ID',
1075
+ inputSchema: {
1076
+ account: accountEnum.describe('Google account alias'),
1077
+ draftId: z.string().min(1).describe('Draft ID to delete'),
1078
+ },
1079
+ }, async ({ account, draftId }) => {
1080
+ try {
1081
+ const auth = await getClientFn(account);
1082
+ const gmail = gmailClient({ version: 'v1', auth });
1083
+ await gmail.users.drafts.delete({ userId: 'me', id: draftId });
1084
+ return {
1085
+ content: [{ type: 'text', text: JSON.stringify({ deleted: true, draftId }, null, 2) }],
1086
+ };
1087
+ }
1088
+ catch (error) {
1089
+ const mapped = composeErrorResult(error, account);
1090
+ return mapped ?? handleGmailError(error, account);
1091
+ }
1092
+ });
996
1093
  server.registerTool('gmail_list_labels', {
997
1094
  description: 'List all Gmail labels (system and user-defined). Use to get label IDs for gmail_modify_labels.',
998
1095
  inputSchema: {
@@ -13,12 +13,12 @@ export const IRREVERSIBLE_TOOLS = new Set([
13
13
  'gmail_send',
14
14
  'gmail_send_draft',
15
15
  'gmail_delete',
16
+ 'gmail_delete_draft',
16
17
  'gmail_batch_delete',
17
18
  'drive_delete',
18
19
  'drive_empty_trash',
19
20
  // generated twins (permanent deletes / real sends)
20
21
  'gmail_users_threads_delete',
21
- 'gmail_users_drafts_delete',
22
22
  'chat_spaces_messages_delete',
23
23
  'cloudidentity_customers_userinvitations_send',
24
24
  'vault_matters_holds_delete',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.73",
3
+ "version": "6.0.0-alpha.74",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",